draft(ios-qa): SwiftUI a11y walker cleanup + GstackProbe + docs (iOS 26 NOT fixed)#1755
draft(ios-qa): SwiftUI a11y walker cleanup + GstackProbe + docs (iOS 26 NOT fixed)#1755sternryan wants to merge 1 commit into
Conversation
The /elements walker returned only top-level `_UIHostingView` /
`HostingView` shells on SwiftUI screens — three entries for a full
Dashboard, all with empty identifier/label and a 0,0,390,844 frame.
SwiftUI vends its AX leaves through the indexed accessor
(`accessibilityElementCount` + `accessibilityElement(at:)`) on the
hosting container, not through `accessibilityElements` (which returns
nil OR an empty array). The previous walker only fell through to the
indexed path on nil, not on empty — so SwiftUI leaves were silently
dropped.
Fix is three coordinated changes to ElementsBridgeImpl plus an opt-in
escape hatch:
1. Force materialization via `UIAccessibility.post(.layoutChanged, …)`
before the walk. Public API, no-op when VoiceOver already populated
the tree, doesn't speak.
2. Always run the indexed accessor when `accessibilityElements` is nil
OR empty. This is the actual SwiftUI path.
3. Read identifier/label/value/traits/frame from synthetic AX elements
via KVC over the documented `UIAccessibility` informal protocol
property names — safe and version-independent across the private
element classes (`_AXSnapshotElement`, `_UIAccessibilityElementMockView`, …).
4. New `.gstackProbe("identifier")` SwiftUI ViewModifier + thread-safe
`GstackProbeRegistry` for views the AX tree refuses to surface
(intentionally hidden, custom Canvas, decorative Shape stacks). Probe
sets `.accessibilityIdentifier(_:)` AND registers (id, frame) in the
registry; the walker merges these in as synthetic entries tagged
`"source":"gstack-probe"`.
Also filters empty container synthetic nodes (no label/id/value/traits)
that were clogging the output before.
Docs in `ios-qa/docs/swiftui-accessibility.md` cover the root cause,
what the fix does/doesn't address, and when to reach for `.gstackProbe`
vs vision-based coordinate tapping.
Typechecks clean against iOS 16 + iOS 17 SDKs, Swift 6 strict
concurrency. Live-device validation pending — PR author will port to
downstream app + report new element count.
Downstream validation — iPhone 12 Pro on iOS 26.3.1Ported this PR's Result: still 3 elements, identical to the pre-PR behavior. Tried with a screenshot-warmup pass before the elements query (in case The walker emits these three only because they hit the Diagnosis: on iOS 26.x SDK, Recommendation:
Tested against: iPhone 12 Pro, iOS 26.3.1, Xcode 26.0.1, Swift 6.2, app target uses iOS 26 deployment min, daemon at |
What this PR is now
Three low-risk, additive changes plus an honest docs page:
Walker cleanup in
ios-qa/templates/Bridges.swift.template— refactor that should be net-positive on every iOS version, no behavior regression observed:emitAXChild()helper (removes duplicated synthetic-element extraction).accessibilityElementsreturns nil OR empty array (the existing template only fell through on nil — SwiftUI hosting views can return empty array with a non-zeroaccessibilityElementCount(), which was getting silently dropped)."source": "ax-synthetic"for downstream agent disambiguation.UIAccessibility.post(notification: .layoutChanged, argument: nil)before walking. Documented public API. Note: likely a working trick on iOS 17-18 per related Swift-Forums + swift-agentation prior art, but does NOT trigger SwiftUI AX tree materialization on iOS 26.3.1 per device validation below..gstackProbe(_:)SwiftUI ViewModifier (new public API in template) — opt-in escape hatch. Apps that need agent visibility on views the AX tree refuses to surface call.gstackProbe("dashboard.captureButton")and the walker merges (id, frame, label) into the/elementssnapshot fromGstackProbeRegistry. UsesGeometryReader { .global }+PreferenceKeyto keep frames current without polling. Pure public API — no private selectors. Not validated on device in this PR — requires explicit per-view instrumentation that wasn't part of the validation app.New docs page
ios-qa/docs/swiftui-accessibility.md— explains the underlying constraint (SwiftUI doesn't always create backing UIViews; AX tree is lazy + VoiceOver-gated), what this PR fixes, what it does NOT fix on iOS 26, and when to reach for.gstackProbe(_:)vs vision-based coordinate tapping.What this PR is NOT
A general fix for the SwiftUI accessibility-walker problem on iOS 26. That problem appears to require either private XCTest APIs (
XCAXClient_iOS, not available in an in-process DEBUG bridge), an actual VoiceOver-running session, or vision-based positioning. The docs page now says this plainly.Validation
Apps/iOS/DebugBridge/Bridges.swift), hitGET /elementsagainst a Dashboard view with declared.accessibilityIdentifier(...)modifiers. Returns 3 elements (same as pre-PR). The walker cleanup is a no-op regression-wise; the SwiftUI tree-walking claim doesn't hold on this iOS version. Full data in comment thread.Package.swift.templatedeployment floor) and Swift 6 strict concurrency..gstackProbe(_:)modifier: untested on device — requires app developer to instrument views with it before there's anything to validate.Sources consulted
GstackProbe.Suggested merge disposition
Three reasonable paths, in increasing order of conservatism:
.gstackProbe(_:)modifier + docs are all useful additions; the docs page is explicit about iOS 26 limitations..gstackProbe(_:)until someone validates it on device.swiftui-accessibility.md+ filters the noise-element cleanup, no API surface changes.Author's recommendation: B — gets the cleanup + the honest docs in front of users, defers the new API until it has device evidence.
Tested against: iPhone 12 Pro, iOS 26.3.1, Xcode 26.0.1, Swift 6.2.