diff --git a/.github/workflows/native-ui-engine.yml b/.github/workflows/native-ui-engine.yml index 73cd62a..034b7e4 100644 --- a/.github/workflows/native-ui-engine.yml +++ b/.github/workflows/native-ui-engine.yml @@ -35,9 +35,10 @@ jobs: POPOVER_CLOSE=NativeUIPluginTests/NativeUISnapshotTests/testNativePopoverClosureNotifiesWebBeforeReopening POPOVER_REPLACE=NativeUIPluginTests/NativeUISnapshotTests/testPopoverReopensAfterPresentationIdentityKeepsReplacedSource COVERED_ORIENTATION=NativeUIPluginTests/NativeUIPluginLifecycleTests/testOrientationPreservesCoveredControlsUntilRestore + SOURCE_ORDER=NativeUIPluginTests/NativeUISnapshotTests/testViewCapturePreservesNativeSourceOrder xcodebuild -scheme RdlaboCapacitorNativeUi-Package -destination "platform=iOS Simulator,id=$DEVICE" CODE_SIGNING_ALLOWED=NO -clonedSourcePackagesDirPath "$RUNNER_TEMP/native-ui-spm" -skip-testing:"$POPOVER_CLOSE" -skip-testing:"$POPOVER_REPLACE" -skip-testing:"$COVERED_ORIENTATION" test cd ios/Tests - xcodebuild -project NativeUIHostedTests.xcodeproj -scheme NativeUIHostedTests -destination "platform=iOS Simulator,id=$DEVICE" CODE_SIGNING_ALLOWED=NO -clonedSourcePackagesDirPath "$RUNNER_TEMP/native-ui-spm" -only-testing:"$POPOVER_CLOSE" -only-testing:"$POPOVER_REPLACE" -only-testing:"$COVERED_ORIENTATION" test + xcodebuild -project NativeUIHostedTests.xcodeproj -scheme NativeUIHostedTests -destination "platform=iOS Simulator,id=$DEVICE" CODE_SIGNING_ALLOWED=NO -clonedSourcePackagesDirPath "$RUNNER_TEMP/native-ui-spm" -only-testing:"$POPOVER_CLOSE" -only-testing:"$POPOVER_REPLACE" -only-testing:"$COVERED_ORIENTATION" -only-testing:"$SOURCE_ORDER" test android: runs-on: ubuntu-latest steps: diff --git a/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift b/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift index 906ecfc..25fff79 100644 --- a/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -6,6 +6,52 @@ import Capacitor @testable import NativeUICore final class NativeUISnapshotTests: XCTestCase { + @MainActor func testViewCapturePreservesNativeSourceOrder() async throws { + guard UIApplication.shared.delegate != nil else { throw XCTSkip("Requires hosted UIApplication") } + guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native controls") } + let window = UIWindow(frame: UIScreen.main.bounds) + let owner = UIViewController(); window.rootViewController = owner; window.makeKeyAndVisible() + let web = WKWebView(frame: window.bounds); owner.view.addSubview(web) + let controller = NativeUIController(environment: NativeUIEnvironment(webView: { web }, viewController: { owner }, emit: { _, _ in }, hasListeners: { _ in false }, trigger: { _, _ in })) + defer { window.isHidden = true } + let nodes = [control(["id": "a", "items": [item(["id": "a-action", "label": "Shared", "buttonFill": "clear", "color": "rgb(255, 0, 0)"])]]), + control(["id": "b", "items": [item(["id": "b-action", "label": "Shared", "buttonFill": "clear", "color": "rgb(0, 0, 255)"])]]), + control(["id": "c", "x": 160.0])] + let applied: [String: Any] = await withCheckedContinuation { continuation in + controller.update(NativeUICall(options: ["revision": 1, "viewportWidth": Double(web.bounds.width), "controls": nodes], + resolve: { continuation.resume(returning: $0) }, reject: { continuation.resume(returning: ["error": $0]) })) + } + XCTAssertNil(applied["error"]) + window.layoutIfNeeded() + func buttons(_ view: UIView) -> [UIButton] { (view as? UIButton).map { [$0] } ?? view.subviews.flatMap(buttons) } + let button = try XCTUnwrap(buttons(owner.view).first { $0.accessibilityIdentifier == "a-action" }) + func capture() async -> [String: Any] { + await withCheckedContinuation { continuation in + controller.coveredSnapshot(NativeUICall(options: ["action": "prepare", "ids": ["a", "b"], "revision": 1, "captureMode": "view", "viewportWidth": Double(web.bounds.width)], + resolve: { continuation.resume(returning: $0) }, reject: { continuation.resume(returning: ["error": $0]) })) + } + } + func pixels(_ result: [String: Any]) throws -> Data { + let parts = try XCTUnwrap(result["parts"] as? [[String: Any]]) + let path = try XCTUnwrap(parts.first?["url"] as? String) + let image = try XCTUnwrap(UIImage(data: Data(contentsOf: XCTUnwrap(URL(string: path))))?.cgImage) + return try XCTUnwrap(image.dataProvider?.data) as Data + } + func stablePixels() async throws -> Data { + let first = await capture() + let expected = try pixels(first) + for _ in 0..<4 { + let repeated = await capture() + XCTAssertEqual(try pixels(repeated), expected, "Repeated handoffs must keep the native drawing order") + } + return expected + } + let before = try await stablePixels() + button.superview?.bringSubviewToFront(button) + let after = try await stablePixels() + XCTAssertNotEqual(after, before, "Overlapping controls must use their current native drawing order") + } + @MainActor func testProminentButtonStyleDecodesAndResetsTintWhenReused() throws { guard #available(iOS 26.0, *) else { throw XCTSkip("Requires glass buttons") } let rendering = NativeUIControlRendering() diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift index 3d53e31..22d44aa 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift @@ -311,7 +311,9 @@ public final class NativeUIController: NSObject, UITabBarDelegate { ids.isDisjoint(with: railIDs) || ids.isSuperset(of: railIDs) else { return nil } - var views = Array(self.controls.filter { ids.contains($0.key) }.values) + let selected = Array(self.controls.filter { ids.contains($0.key) }.values) + var views = selected + if let ordered = self.host?.subviews.filter({ selected.contains($0) }), ordered.count == selected.count { views = ordered } if !railIDs.isEmpty, ids.isSuperset(of: railIDs), let rail = self.verticalBars { views.append(rail.view) } return views }