From 47f7cde6f2fb58fcbc8aca0a43cda5133d2d81eb Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 10 Nov 2026 20:58:59 +0900 Subject: [PATCH 1/4] feat(ios): declare capture readiness from native controls --- .../NativeUISnapshotTests.swift | 111 ++++++++++++++++++ .../Components/NativeUIButton.swift | 7 ++ .../Components/NativeUIButtons.swift | 1 + .../NativeUICore/Components/NativeUIFab.swift | 1 + .../NativeUICore/NativeUICaptureState.swift | 13 ++ .../NativeUICore/NativeUIController.swift | 80 +++++++++++-- 6 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 packages/native-ui-core/ios/Sources/NativeUICore/NativeUICaptureState.swift diff --git a/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift b/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift index 906ecfc..80c1717 100644 --- a/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -6,6 +6,105 @@ import Capacitor @testable import NativeUICore final class NativeUISnapshotTests: XCTestCase { + @MainActor func testCaptureInteractionMatrixKeepsOtherOwnersDirty() { + let state = NativeUICaptureState() + let a = UIView(); let b = UIView() + XCTAssertTrue(state.appearanceSettled([b])) + state.interacted([a]) + XCTAssertFalse(state.appearanceSettled([a])) + XCTAssertTrue(state.appearanceSettled([b])) + state.interacted([b]) + XCTAssertFalse(state.appearanceSettled([a]), "A different capture cannot prove A's animation finished") + XCTAssertFalse(state.appearanceSettled([b])) + XCTAssertTrue(state.appearanceSettled([UIView()]), "A genuinely new source has no old interaction") + weak var released: UIView? + autoreleasepool { + let temporary = UIView(); released = temporary; state.interacted([temporary]) + } + XCTAssertNil(released, "Interaction history must not retain retired components") + } + + @MainActor func testControllerReadinessTracksActualOwnersAndCaptureAcknowledgements() 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": "A", "buttonFill": "clear", "transitionId": "readiness-a"])]]), + control(["id": "b", "x": 90.0, "items": [item(["id": "b-action", "label": "B", "buttonFill": "clear"])]]), + control(["id": "glass", "x": 160.0, "items": [item(["id": "glass-action", "label": "Glass"]) ]])] + let applied: [String: Any] = await withCheckedContinuation { continuation in + controller.update(["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.configuration?.title == "A" }) + XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).isSettled) + XCTAssertFalse(controller.captureReadiness(["glass"], revision: 1).isSettled) + let other = try XCTUnwrap(buttons(owner.view).first { $0.configuration?.title == "B" }) + other.layer.mask = CALayer() + XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).isSettled, "Concealed pixels cannot declare readiness") + other.layer.mask = nil + + button.sendActions(for: .touchUpInside) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled, "UIAction without UITouch must invalidate its owner") + XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).isSettled) + // A real UIAction must invalidate readiness even without a UITouch. + controller.recordCaptureInteraction(button.titleLabel ?? button) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled) + XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).isSettled) + func capture(_ ids: [String]) async -> [String: Any] { + await withCheckedContinuation { continuation in + controller.coveredSnapshot(["action": "prepare", "ids": ids, "revision": 1, "captureMode": "view", "viewportWidth": Double(web.bounds.width)], + resolve: { continuation.resume(returning: $0) }, reject: { continuation.resume(returning: ["error": $0]) }) + } + } + let pair = await capture(["a", "b"]) + XCTAssertNotNil(pair["token"]) + let samePair = await capture(["a", "b"]) + XCTAssertEqual(samePair["reused"] as? Bool, true, "Partial selection must keep native drawing order") + button.superview?.bringSubviewToFront(button) + let reorderedPair = await capture(["a", "b"]) + XCTAssertNotEqual(reorderedPair["reused"] as? Bool, true, "A real native z-order change must recapture") + button.superview?.insertSubview(button, at: 0) + let bResult = await capture(["b"]) + XCTAssertNotNil(bResult["token"]) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled) + let aResult = await capture(["a"]) + XCTAssertNotNil(aResult["token"]) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled, "Snapshot completion is not animation completion") + controller.recordCaptureInteraction(nil) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled) + XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).isSettled) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 0).isSettled) + var rebound = nodes + rebound[0]["id"] = "renamed" + _ = await withCheckedContinuation { continuation in + controller.update(["revision": 2, "viewportWidth": Double(web.bounds.width), "controls": rebound], + resolve: { continuation.resume(returning: $0) }, reject: { _ in continuation.resume(returning: [:]) }) + } + window.layoutIfNeeded() + XCTAssertTrue(buttons(owner.view).contains { $0 === button }, "The source must actually be reassociated") + XCTAssertFalse(controller.captureReadiness(["renamed"], revision: 2).isSettled, "ID reassociation must not forget an unsettled interaction") + _ = await withCheckedContinuation { continuation in controller.clear(["revision": 3], resolve: { continuation.resume(returning: $0) }, reject: { _ in continuation.resume(returning: [:]) }) } + } + + @MainActor func testCaptureEffectsAreDeclaredByComponentIncludingOuterGlass() throws { + let plain = try XCTUnwrap(decode([control(["kind": "ion-button", "items": [item(["buttonFill": "clear"])]])]).controls.first) + let glass = try XCTUnwrap(decode([control(["kind": "ion-button", "items": [item() ]])]).controls.first) + XCTAssertEqual(NativeUIButton.captureEffect(plain), .independent) + XCTAssertEqual(NativeUIButton.captureEffect(glass), .backdrop) + let prominent = try XCTUnwrap(decode([control(["items": [item(["buttonFill": "clear", "buttonStyle": "prominent"]) ]])]).controls.first) + XCTAssertEqual(NativeUIButton.captureEffect(prominent), .backdrop, "Prominent style uses Glass even with clear fill") + XCTAssertEqual(NativeUIButtons.captureEffect, .backdrop, "Plain children do not remove outer Glass") + XCTAssertEqual(NativeUIFab.captureEffect, .backdrop) + } + + @MainActor func testProminentButtonStyleDecodesAndResetsTintWhenReused() throws { guard #available(iOS 26.0, *) else { throw XCTSkip("Requires glass buttons") } let rendering = NativeUIControlRendering() @@ -1599,3 +1698,15 @@ final class NativeUISnapshotTests: XCTestCase { } } + +private extension NativeUIController { + func update(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) { + update(NativeUICall(options: options, resolve: resolve, reject: reject)) + } + func clear(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) { + clear(NativeUICall(options: options, resolve: resolve, reject: reject)) + } + func coveredSnapshot(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) { + coveredSnapshot(NativeUICall(options: options, resolve: resolve, reject: reject)) + } +} diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButton.swift b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButton.swift index 55f80ca..f33e866 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButton.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButton.swift @@ -30,6 +30,13 @@ enum NativeUIButton { // These Ionic components share the same native UIButton presentation. static let kinds: [NativeUIComponent] = [.button, .backButton, .menuButton] + // The component declares the same material choice used by make(). + static func captureEffect(_ node: NativeUIControl) -> NativeUICaptureEffect { + guard kinds.contains(node.kind), node.placement == nil, node.scroll == nil, node.items.count == 1, + let item = node.items.first else { return .unknown } + return item.content.buttonFill == .clear && item.content.buttonStyle != .prominent ? .independent : .backdrop + } + @available(iOS 26.0, *) static func make(_ node: NativeUIControl, existing: UIButton? = nil, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIButton? { guard node.items.count == 1, let item = node.items.first else { return nil } diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButtons.swift b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButtons.swift index 9bfddee..73944c1 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButtons.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButtons.swift @@ -1,6 +1,7 @@ import UIKit enum NativeUIButtons { + static let captureEffect = NativeUICaptureEffect.backdrop static let kind = NativeUIComponent.buttons @available(iOS 26.0, *) diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIFab.swift b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIFab.swift index 77cda7e..2c43b01 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIFab.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIFab.swift @@ -1,6 +1,7 @@ import UIKit final class NativeUIFab: UIView { + static let captureEffect = NativeUICaptureEffect.backdrop static let kind = NativeUIComponent.fab var buttons: [String: UIButton] = [:] var content: [String: NativeUIItemContent] = [:] diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUICaptureState.swift b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUICaptureState.swift new file mode 100644 index 0000000..cfbdc7c --- /dev/null +++ b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUICaptureState.swift @@ -0,0 +1,13 @@ +import UIKit + +// Component effects belong to the renderer, not the capture engine. +enum NativeUICaptureEffect { case independent, backdrop, unknown } + +// A capture ACK does not prove that UIKit's press animation finished. +// Track the actual source weakly, so reassociation keeps its interaction state +// while a genuinely new component starts clean without retaining old views. +final class NativeUICaptureState { + private let dirty = NSHashTable.weakObjects() + func interacted(_ views: [UIView]) { views.forEach(dirty.add) } + func appearanceSettled(_ views: [UIView]) -> Bool { views.allSatisfy { !dirty.contains($0) } } +} diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift index 3d53e31..130e224 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift @@ -13,6 +13,9 @@ public final class NativeUIController: NSObject, UITabBarDelegate { private func notifyListeners(_ event: String, data: [String: Any]) { environment.emit(event, data) } private func hasListeners(_ event: String) -> Bool { environment.hasListeners(event) } private let coveredSnapshots = NativeUICoveredSnapshots() + private let captureState = NativeUICaptureState() + private var captureLayoutRevision: Int? + private weak var captureObservationWindow: UIWindow? private var snapshotControls: [NativeUIControl] = [] private var overlays: NativeUIOverlayController? private var toolbarTransition: NativeUIToolbarTransition? @@ -245,20 +248,71 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } } + private func observeCaptureInteractions() { + guard let window = environment.webView()?.window else { return } + if captureObservationWindow !== window { + captureState.interacted(Array(controls.values) + (verticalBars.map { [$0.view] } ?? [])) + captureObservationWindow = window + } + coveredSnapshots.observe(window, interacted: { [weak self] touched in + guard let self else { return } + self.recordCaptureInteraction(touched) + }) { [weak self] active in + self?.notifyListeners("snapshotInteraction", data: ["active": active]) + } + } + + func recordCaptureInteraction(_ touched: UIView?) { + let all = Array(controls.values) + (verticalBars.map { [$0.view] } ?? []) + guard let touched else { captureState.interacted(all); return } + if let rail = verticalBars, touched.isDescendant(of: rail.view) { + captureState.interacted([rail.view]); return + } + let owners = controls.values.filter { touched.isDescendant(of: $0) } + captureState.interacted(owners.isEmpty ? all : Array(owners)) + } + + func captureReadiness(_ ids: Set, revision: Int) -> NativeUICaptureReadiness { + guard revision == self.revision, captureLayoutRevision == revision, + captureObservationWindow === environment.webView()?.window, environment.webView()?.window != nil, + !keyboardVisible, toolbarTransition == nil else { return .init() } + let nodes = snapshotControls.filter { ids.contains($0.id) } + guard !ids.isEmpty, nodes.count == ids.count else { return .init() } + let effects = nodes.map { node -> NativeUICaptureEffect in + if node.kind == NativeUIButtons.kind { return NativeUIButtons.captureEffect } + if node.kind == NativeUIFab.kind { return NativeUIFab.captureEffect } + return NativeUIButton.captureEffect(node) + } + let effect: NativeUICaptureEffect = effects.allSatisfy { $0 == .independent } ? .independent : + effects.contains(.backdrop) ? .backdrop : .unknown + // update() acknowledges our explicit, nonanimated layout transaction. + // UIKit may dirty internal layout flags while drawing without changing + // that transaction; the Engine separately rechecks actual frames. + let settled = nodes.allSatisfy { node in + guard let button = controls[node.id] as? UIButton, !button.isHighlighted, !button.isTracking, + button.window != nil, button.layer.mask == nil else { return false } + var view: UIView? = button + while let current = view { + if current.isHidden || current.alpha <= 0 || !(current.layer.animationKeys() ?? []).isEmpty { return false } + view = current.superview + } + return true + } + return .init(isSettled: effect == .independent && settled && + captureState.appearanceSettled(nodes.compactMap { controls[$0.id] })) + } + private func resetCoveredSnapshots() { imagePixelRestorations.forEach { $0() } imagePixelRestorations.removeAll() coveredSnapshots.reset() + captureLayoutRevision = nil } public func coveredSnapshot(_ call: NativeUICall) { DispatchQueue.main.async { [weak self] in guard let self, let webView = self.environment.webView() else { call.reject("WebView unavailable"); return } - if call.getString("action") == "prepare", let window = webView.window { - self.coveredSnapshots.observe(window) { [weak self] active in - self?.notifyListeners("snapshotInteraction", data: ["active": active]) - } - } + self.observeCaptureInteractions() var captureAnchors: [String: CGRect] = [:] let measure: (Set, Int) -> [String: CGRect] = { [weak self, weak webView] ids, revision in guard let self, let webView, revision == self.revision, @@ -311,11 +365,13 @@ 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 } - self.coveredSnapshots.handle(call.options, in: webView, frames: measure, anchorFrames: { captureAnchors }, views: views, complete: { call.resolve($0) }) + self.coveredSnapshots.handle(call.options, in: webView, frames: measure, anchorFrames: { captureAnchors }, views: views, readiness: self.captureReadiness, complete: { call.resolve($0) }) } } @@ -378,6 +434,7 @@ public final class NativeUIController: NSObject, UITabBarDelegate { guard let self else { call.resolve(); return } let next = call.getInt("revision") ?? 0 if next >= self.revision { + self.captureLayoutRevision = nil self.revision = next self.restoreTopEdge?() self.restoreTopEdge = nil @@ -522,6 +579,8 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } let verticalSearchActive = verticalBars.contains { $0.search.map { $0.available && $0.active } ?? false } let width = snapshot.viewportWidth + self.captureLayoutRevision = nil + self.observeCaptureInteractions() self.revision = next if !pendingVerticalNavigation && snapshots.isEmpty && verticalBars.isEmpty && accessories.isEmpty && (snapshot.toolbarTransition?.destination.isEmpty ?? true) { self.removeControls(duration: duration) @@ -784,6 +843,7 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } self.snapshotControls = snapshot.controls.filter { !rejectedControls.contains($0.id) } let complete = { + if self.revision == next { self.captureLayoutRevision = next } var result: [String: Any] = ["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls, "tabBarWidths": tabBarWidths] if let transition = self.toolbarTransition { result["toolbarTransition"] = transition.id } call.resolve(result) @@ -814,6 +874,12 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } private func activate(_ id: String) { + // Keyboard/VoiceOver activation can bypass UITouch delivery. + let owners = snapshotControls.filter { $0.id == id || $0.items.contains { $0.id == id } } + let sources = owners.compactMap { controls[$0.id] ?? ($0.placement == .verticalBars ? verticalBars?.view : nil) } + if sources.isEmpty { recordCaptureInteraction(nil) } else { + sources.forEach { recordCaptureInteraction($0) } + } sequence += 1 notifyListeners("activate", data: ["id": id, "revision": revision, "sequence": sequence]) From 64e1ead6ef51d58371b34dc20b9d8c7f0f3b2031 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 10 Nov 2026 21:33:25 +0900 Subject: [PATCH 2/4] ci: run caller readiness checks in the hosted plugin suite --- .github/workflows/native-ui-engine.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/native-ui-engine.yml b/.github/workflows/native-ui-engine.yml index 73cd62a..78ca312 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 + CAPTURE_READINESS=NativeUIPluginTests/NativeUISnapshotTests/testControllerReadinessTracksActualOwnersAndCaptureAcknowledgements 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:"$CAPTURE_READINESS" test android: runs-on: ubuntu-latest steps: From 000470c07c41d4a85445939d1721c135d0db246e Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 10 Nov 2026 23:45:41 +0900 Subject: [PATCH 3/4] refactor(ios): keep readiness eligibility as a component predicate --- .../NativeUIPluginTests/NativeUISnapshotTests.swift | 12 +++++++----- .../NativeUICore/Components/NativeUIButton.swift | 6 +++--- .../NativeUICore/Components/NativeUIButtons.swift | 1 - .../NativeUICore/Components/NativeUIFab.swift | 1 - .../Sources/NativeUICore/NativeUICaptureState.swift | 3 --- .../Sources/NativeUICore/NativeUIController.swift | 10 ++-------- 6 files changed, 12 insertions(+), 21 deletions(-) diff --git a/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift b/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift index 80c1717..4cfcc1b 100644 --- a/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -96,12 +96,14 @@ final class NativeUISnapshotTests: XCTestCase { @MainActor func testCaptureEffectsAreDeclaredByComponentIncludingOuterGlass() throws { let plain = try XCTUnwrap(decode([control(["kind": "ion-button", "items": [item(["buttonFill": "clear"])]])]).controls.first) let glass = try XCTUnwrap(decode([control(["kind": "ion-button", "items": [item() ]])]).controls.first) - XCTAssertEqual(NativeUIButton.captureEffect(plain), .independent) - XCTAssertEqual(NativeUIButton.captureEffect(glass), .backdrop) + XCTAssertTrue(NativeUIButton.supportsSettledCapture(plain)) + XCTAssertFalse(NativeUIButton.supportsSettledCapture(glass)) let prominent = try XCTUnwrap(decode([control(["items": [item(["buttonFill": "clear", "buttonStyle": "prominent"]) ]])]).controls.first) - XCTAssertEqual(NativeUIButton.captureEffect(prominent), .backdrop, "Prominent style uses Glass even with clear fill") - XCTAssertEqual(NativeUIButtons.captureEffect, .backdrop, "Plain children do not remove outer Glass") - XCTAssertEqual(NativeUIFab.captureEffect, .backdrop) + XCTAssertFalse(NativeUIButton.supportsSettledCapture(prominent), "Prominent style uses Glass even with clear fill") + for kind in ["ion-buttons", "ion-fab"] { + let shared = try XCTUnwrap(decode([control(["kind": kind, "items": [item(["buttonFill": "clear"]) ]])]).controls.first) + XCTAssertFalse(NativeUIButton.supportsSettledCapture(shared), "Plain children do not remove outer Glass") + } } diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButton.swift b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButton.swift index f33e866..809fdd5 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButton.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButton.swift @@ -31,10 +31,10 @@ enum NativeUIButton { static let kinds: [NativeUIComponent] = [.button, .backButton, .menuButton] // The component declares the same material choice used by make(). - static func captureEffect(_ node: NativeUIControl) -> NativeUICaptureEffect { + static func supportsSettledCapture(_ node: NativeUIControl) -> Bool { guard kinds.contains(node.kind), node.placement == nil, node.scroll == nil, node.items.count == 1, - let item = node.items.first else { return .unknown } - return item.content.buttonFill == .clear && item.content.buttonStyle != .prominent ? .independent : .backdrop + let item = node.items.first else { return false } + return item.content.buttonFill == .clear && item.content.buttonStyle != .prominent } @available(iOS 26.0, *) diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButtons.swift b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButtons.swift index 73944c1..9bfddee 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButtons.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButtons.swift @@ -1,7 +1,6 @@ import UIKit enum NativeUIButtons { - static let captureEffect = NativeUICaptureEffect.backdrop static let kind = NativeUIComponent.buttons @available(iOS 26.0, *) diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIFab.swift b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIFab.swift index 2c43b01..77cda7e 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIFab.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIFab.swift @@ -1,7 +1,6 @@ import UIKit final class NativeUIFab: UIView { - static let captureEffect = NativeUICaptureEffect.backdrop static let kind = NativeUIComponent.fab var buttons: [String: UIButton] = [:] var content: [String: NativeUIItemContent] = [:] diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUICaptureState.swift b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUICaptureState.swift index cfbdc7c..55c7408 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUICaptureState.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUICaptureState.swift @@ -1,8 +1,5 @@ import UIKit -// Component effects belong to the renderer, not the capture engine. -enum NativeUICaptureEffect { case independent, backdrop, unknown } - // A capture ACK does not prove that UIKit's press animation finished. // Track the actual source weakly, so reassociation keeps its interaction state // while a genuinely new component starts clean without retaining old views. diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift index 130e224..c546dc1 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift @@ -278,13 +278,7 @@ public final class NativeUIController: NSObject, UITabBarDelegate { !keyboardVisible, toolbarTransition == nil else { return .init() } let nodes = snapshotControls.filter { ids.contains($0.id) } guard !ids.isEmpty, nodes.count == ids.count else { return .init() } - let effects = nodes.map { node -> NativeUICaptureEffect in - if node.kind == NativeUIButtons.kind { return NativeUIButtons.captureEffect } - if node.kind == NativeUIFab.kind { return NativeUIFab.captureEffect } - return NativeUIButton.captureEffect(node) - } - let effect: NativeUICaptureEffect = effects.allSatisfy { $0 == .independent } ? .independent : - effects.contains(.backdrop) ? .backdrop : .unknown + guard nodes.allSatisfy(NativeUIButton.supportsSettledCapture) else { return .init() } // update() acknowledges our explicit, nonanimated layout transaction. // UIKit may dirty internal layout flags while drawing without changing // that transaction; the Engine separately rechecks actual frames. @@ -298,7 +292,7 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } return true } - return .init(isSettled: effect == .independent && settled && + return .init(isSettled: settled && captureState.appearanceSettled(nodes.compactMap { controls[$0.id] })) } From ff13712f4c2763a672e3d5a99e6cac182c01061d Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 11 Nov 2026 00:18:24 +0900 Subject: [PATCH 4/4] fix(ios): limit snapshot changes to native drawing order --- .github/workflows/native-ui-engine.yml | 4 +- .../NativeUISnapshotTests.swift | 121 ++++-------------- .../Components/NativeUIButton.swift | 7 - .../NativeUICore/NativeUICaptureState.swift | 10 -- .../NativeUICore/NativeUIController.swift | 70 +--------- 5 files changed, 35 insertions(+), 177 deletions(-) delete mode 100644 packages/native-ui-core/ios/Sources/NativeUICore/NativeUICaptureState.swift diff --git a/.github/workflows/native-ui-engine.yml b/.github/workflows/native-ui-engine.yml index 78ca312..034b7e4 100644 --- a/.github/workflows/native-ui-engine.yml +++ b/.github/workflows/native-ui-engine.yml @@ -35,10 +35,10 @@ jobs: POPOVER_CLOSE=NativeUIPluginTests/NativeUISnapshotTests/testNativePopoverClosureNotifiesWebBeforeReopening POPOVER_REPLACE=NativeUIPluginTests/NativeUISnapshotTests/testPopoverReopensAfterPresentationIdentityKeepsReplacedSource COVERED_ORIENTATION=NativeUIPluginTests/NativeUIPluginLifecycleTests/testOrientationPreservesCoveredControlsUntilRestore - CAPTURE_READINESS=NativeUIPluginTests/NativeUISnapshotTests/testControllerReadinessTracksActualOwnersAndCaptureAcknowledgements + 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" -only-testing:"$CAPTURE_READINESS" 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 4cfcc1b..25fff79 100644 --- a/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -6,25 +6,7 @@ import Capacitor @testable import NativeUICore final class NativeUISnapshotTests: XCTestCase { - @MainActor func testCaptureInteractionMatrixKeepsOtherOwnersDirty() { - let state = NativeUICaptureState() - let a = UIView(); let b = UIView() - XCTAssertTrue(state.appearanceSettled([b])) - state.interacted([a]) - XCTAssertFalse(state.appearanceSettled([a])) - XCTAssertTrue(state.appearanceSettled([b])) - state.interacted([b]) - XCTAssertFalse(state.appearanceSettled([a]), "A different capture cannot prove A's animation finished") - XCTAssertFalse(state.appearanceSettled([b])) - XCTAssertTrue(state.appearanceSettled([UIView()]), "A genuinely new source has no old interaction") - weak var released: UIView? - autoreleasepool { - let temporary = UIView(); released = temporary; state.interacted([temporary]) - } - XCTAssertNil(released, "Interaction history must not retain retired components") - } - - @MainActor func testControllerReadinessTracksActualOwnersAndCaptureAcknowledgements() async throws { + @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) @@ -32,81 +14,44 @@ final class NativeUISnapshotTests: XCTestCase { 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": "A", "buttonFill": "clear", "transitionId": "readiness-a"])]]), - control(["id": "b", "x": 90.0, "items": [item(["id": "b-action", "label": "B", "buttonFill": "clear"])]]), - control(["id": "glass", "x": 160.0, "items": [item(["id": "glass-action", "label": "Glass"]) ]])] + 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(["revision": 1, "viewportWidth": Double(web.bounds.width), "controls": nodes], - resolve: { continuation.resume(returning: $0) }, reject: { continuation.resume(returning: ["error": $0]) }) + 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.configuration?.title == "A" }) - XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).isSettled) - XCTAssertFalse(controller.captureReadiness(["glass"], revision: 1).isSettled) - let other = try XCTUnwrap(buttons(owner.view).first { $0.configuration?.title == "B" }) - other.layer.mask = CALayer() - XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).isSettled, "Concealed pixels cannot declare readiness") - other.layer.mask = nil - - button.sendActions(for: .touchUpInside) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled, "UIAction without UITouch must invalidate its owner") - XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).isSettled) - // A real UIAction must invalidate readiness even without a UITouch. - controller.recordCaptureInteraction(button.titleLabel ?? button) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled) - XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).isSettled) - func capture(_ ids: [String]) async -> [String: Any] { + let button = try XCTUnwrap(buttons(owner.view).first { $0.accessibilityIdentifier == "a-action" }) + func capture() async -> [String: Any] { await withCheckedContinuation { continuation in - controller.coveredSnapshot(["action": "prepare", "ids": ids, "revision": 1, "captureMode": "view", "viewportWidth": Double(web.bounds.width)], - resolve: { continuation.resume(returning: $0) }, reject: { continuation.resume(returning: ["error": $0]) }) + 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]) })) } } - let pair = await capture(["a", "b"]) - XCTAssertNotNil(pair["token"]) - let samePair = await capture(["a", "b"]) - XCTAssertEqual(samePair["reused"] as? Bool, true, "Partial selection must keep native drawing order") - button.superview?.bringSubviewToFront(button) - let reorderedPair = await capture(["a", "b"]) - XCTAssertNotEqual(reorderedPair["reused"] as? Bool, true, "A real native z-order change must recapture") - button.superview?.insertSubview(button, at: 0) - let bResult = await capture(["b"]) - XCTAssertNotNil(bResult["token"]) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled) - let aResult = await capture(["a"]) - XCTAssertNotNil(aResult["token"]) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled, "Snapshot completion is not animation completion") - controller.recordCaptureInteraction(nil) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled) - XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).isSettled) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 0).isSettled) - var rebound = nodes - rebound[0]["id"] = "renamed" - _ = await withCheckedContinuation { continuation in - controller.update(["revision": 2, "viewportWidth": Double(web.bounds.width), "controls": rebound], - resolve: { continuation.resume(returning: $0) }, reject: { _ in continuation.resume(returning: [:]) }) + 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 } - window.layoutIfNeeded() - XCTAssertTrue(buttons(owner.view).contains { $0 === button }, "The source must actually be reassociated") - XCTAssertFalse(controller.captureReadiness(["renamed"], revision: 2).isSettled, "ID reassociation must not forget an unsettled interaction") - _ = await withCheckedContinuation { continuation in controller.clear(["revision": 3], resolve: { continuation.resume(returning: $0) }, reject: { _ in continuation.resume(returning: [:]) }) } - } - - @MainActor func testCaptureEffectsAreDeclaredByComponentIncludingOuterGlass() throws { - let plain = try XCTUnwrap(decode([control(["kind": "ion-button", "items": [item(["buttonFill": "clear"])]])]).controls.first) - let glass = try XCTUnwrap(decode([control(["kind": "ion-button", "items": [item() ]])]).controls.first) - XCTAssertTrue(NativeUIButton.supportsSettledCapture(plain)) - XCTAssertFalse(NativeUIButton.supportsSettledCapture(glass)) - let prominent = try XCTUnwrap(decode([control(["items": [item(["buttonFill": "clear", "buttonStyle": "prominent"]) ]])]).controls.first) - XCTAssertFalse(NativeUIButton.supportsSettledCapture(prominent), "Prominent style uses Glass even with clear fill") - for kind in ["ion-buttons", "ion-fab"] { - let shared = try XCTUnwrap(decode([control(["kind": kind, "items": [item(["buttonFill": "clear"]) ]])]).controls.first) - XCTAssertFalse(NativeUIButton.supportsSettledCapture(shared), "Plain children do not remove outer Glass") + 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() @@ -1700,15 +1645,3 @@ final class NativeUISnapshotTests: XCTestCase { } } - -private extension NativeUIController { - func update(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) { - update(NativeUICall(options: options, resolve: resolve, reject: reject)) - } - func clear(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) { - clear(NativeUICall(options: options, resolve: resolve, reject: reject)) - } - func coveredSnapshot(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) { - coveredSnapshot(NativeUICall(options: options, resolve: resolve, reject: reject)) - } -} diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButton.swift b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButton.swift index 809fdd5..55f80ca 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButton.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/Components/NativeUIButton.swift @@ -30,13 +30,6 @@ enum NativeUIButton { // These Ionic components share the same native UIButton presentation. static let kinds: [NativeUIComponent] = [.button, .backButton, .menuButton] - // The component declares the same material choice used by make(). - static func supportsSettledCapture(_ node: NativeUIControl) -> Bool { - guard kinds.contains(node.kind), node.placement == nil, node.scroll == nil, node.items.count == 1, - let item = node.items.first else { return false } - return item.content.buttonFill == .clear && item.content.buttonStyle != .prominent - } - @available(iOS 26.0, *) static func make(_ node: NativeUIControl, existing: UIButton? = nil, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIButton? { guard node.items.count == 1, let item = node.items.first else { return nil } diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUICaptureState.swift b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUICaptureState.swift deleted file mode 100644 index 55c7408..0000000 --- a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUICaptureState.swift +++ /dev/null @@ -1,10 +0,0 @@ -import UIKit - -// A capture ACK does not prove that UIKit's press animation finished. -// Track the actual source weakly, so reassociation keeps its interaction state -// while a genuinely new component starts clean without retaining old views. -final class NativeUICaptureState { - private let dirty = NSHashTable.weakObjects() - func interacted(_ views: [UIView]) { views.forEach(dirty.add) } - func appearanceSettled(_ views: [UIView]) -> Bool { views.allSatisfy { !dirty.contains($0) } } -} diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift index c546dc1..22d44aa 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/NativeUIController.swift @@ -13,9 +13,6 @@ public final class NativeUIController: NSObject, UITabBarDelegate { private func notifyListeners(_ event: String, data: [String: Any]) { environment.emit(event, data) } private func hasListeners(_ event: String) -> Bool { environment.hasListeners(event) } private let coveredSnapshots = NativeUICoveredSnapshots() - private let captureState = NativeUICaptureState() - private var captureLayoutRevision: Int? - private weak var captureObservationWindow: UIWindow? private var snapshotControls: [NativeUIControl] = [] private var overlays: NativeUIOverlayController? private var toolbarTransition: NativeUIToolbarTransition? @@ -248,65 +245,20 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } } - private func observeCaptureInteractions() { - guard let window = environment.webView()?.window else { return } - if captureObservationWindow !== window { - captureState.interacted(Array(controls.values) + (verticalBars.map { [$0.view] } ?? [])) - captureObservationWindow = window - } - coveredSnapshots.observe(window, interacted: { [weak self] touched in - guard let self else { return } - self.recordCaptureInteraction(touched) - }) { [weak self] active in - self?.notifyListeners("snapshotInteraction", data: ["active": active]) - } - } - - func recordCaptureInteraction(_ touched: UIView?) { - let all = Array(controls.values) + (verticalBars.map { [$0.view] } ?? []) - guard let touched else { captureState.interacted(all); return } - if let rail = verticalBars, touched.isDescendant(of: rail.view) { - captureState.interacted([rail.view]); return - } - let owners = controls.values.filter { touched.isDescendant(of: $0) } - captureState.interacted(owners.isEmpty ? all : Array(owners)) - } - - func captureReadiness(_ ids: Set, revision: Int) -> NativeUICaptureReadiness { - guard revision == self.revision, captureLayoutRevision == revision, - captureObservationWindow === environment.webView()?.window, environment.webView()?.window != nil, - !keyboardVisible, toolbarTransition == nil else { return .init() } - let nodes = snapshotControls.filter { ids.contains($0.id) } - guard !ids.isEmpty, nodes.count == ids.count else { return .init() } - guard nodes.allSatisfy(NativeUIButton.supportsSettledCapture) else { return .init() } - // update() acknowledges our explicit, nonanimated layout transaction. - // UIKit may dirty internal layout flags while drawing without changing - // that transaction; the Engine separately rechecks actual frames. - let settled = nodes.allSatisfy { node in - guard let button = controls[node.id] as? UIButton, !button.isHighlighted, !button.isTracking, - button.window != nil, button.layer.mask == nil else { return false } - var view: UIView? = button - while let current = view { - if current.isHidden || current.alpha <= 0 || !(current.layer.animationKeys() ?? []).isEmpty { return false } - view = current.superview - } - return true - } - return .init(isSettled: settled && - captureState.appearanceSettled(nodes.compactMap { controls[$0.id] })) - } - private func resetCoveredSnapshots() { imagePixelRestorations.forEach { $0() } imagePixelRestorations.removeAll() coveredSnapshots.reset() - captureLayoutRevision = nil } public func coveredSnapshot(_ call: NativeUICall) { DispatchQueue.main.async { [weak self] in guard let self, let webView = self.environment.webView() else { call.reject("WebView unavailable"); return } - self.observeCaptureInteractions() + if call.getString("action") == "prepare", let window = webView.window { + self.coveredSnapshots.observe(window) { [weak self] active in + self?.notifyListeners("snapshotInteraction", data: ["active": active]) + } + } var captureAnchors: [String: CGRect] = [:] let measure: (Set, Int) -> [String: CGRect] = { [weak self, weak webView] ids, revision in guard let self, let webView, revision == self.revision, @@ -365,7 +317,7 @@ public final class NativeUIController: NSObject, UITabBarDelegate { if !railIDs.isEmpty, ids.isSuperset(of: railIDs), let rail = self.verticalBars { views.append(rail.view) } return views } - self.coveredSnapshots.handle(call.options, in: webView, frames: measure, anchorFrames: { captureAnchors }, views: views, readiness: self.captureReadiness, complete: { call.resolve($0) }) + self.coveredSnapshots.handle(call.options, in: webView, frames: measure, anchorFrames: { captureAnchors }, views: views, complete: { call.resolve($0) }) } } @@ -428,7 +380,6 @@ public final class NativeUIController: NSObject, UITabBarDelegate { guard let self else { call.resolve(); return } let next = call.getInt("revision") ?? 0 if next >= self.revision { - self.captureLayoutRevision = nil self.revision = next self.restoreTopEdge?() self.restoreTopEdge = nil @@ -573,8 +524,6 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } let verticalSearchActive = verticalBars.contains { $0.search.map { $0.available && $0.active } ?? false } let width = snapshot.viewportWidth - self.captureLayoutRevision = nil - self.observeCaptureInteractions() self.revision = next if !pendingVerticalNavigation && snapshots.isEmpty && verticalBars.isEmpty && accessories.isEmpty && (snapshot.toolbarTransition?.destination.isEmpty ?? true) { self.removeControls(duration: duration) @@ -837,7 +786,6 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } self.snapshotControls = snapshot.controls.filter { !rejectedControls.contains($0.id) } let complete = { - if self.revision == next { self.captureLayoutRevision = next } var result: [String: Any] = ["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls, "tabBarWidths": tabBarWidths] if let transition = self.toolbarTransition { result["toolbarTransition"] = transition.id } call.resolve(result) @@ -868,12 +816,6 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } private func activate(_ id: String) { - // Keyboard/VoiceOver activation can bypass UITouch delivery. - let owners = snapshotControls.filter { $0.id == id || $0.items.contains { $0.id == id } } - let sources = owners.compactMap { controls[$0.id] ?? ($0.placement == .verticalBars ? verticalBars?.view : nil) } - if sources.isEmpty { recordCaptureInteraction(nil) } else { - sources.forEach { recordCaptureInteraction($0) } - } sequence += 1 notifyListeners("activate", data: ["id": id, "revision": revision, "sequence": sequence])