Add expanded/collapsed state support for SwiftUI DisclosureGroup#324
Draft
RoyalPineapple wants to merge 6 commits into
Draft
Add expanded/collapsed state support for SwiftUI DisclosureGroup#324RoyalPineapple wants to merge 6 commits into
RoyalPineapple wants to merge 6 commits into
Conversation
21ce7c6 to
2e59b92
Compare
e9cb098 to
6ad2488
Compare
aaaee9e to
201c36b
Compare
Adds support for reading the expanded/collapsed state from any accessibility element that exposes one — including UIKit elements that adopt iOS 18's `accessibilityExpandedStatus` and SwiftUI `DisclosureGroup` / expandable list sections. Snapshots now show whether these elements are expanded or collapsed, matching what VoiceOver announces to users. What changed: - New `ExpandedStatus` enum on `AccessibilityElement` — `unsupported` / `expanded` / `collapsed`. - `PrivateAX` selector catalog with a typed protocol and runtime-safe `responds(to:)` + `method(for:)` invocation. Avoids KVC, which would raise on plain `NSObject`s that respond to the selector but are not KVC-compliant. - Parser reads `_accessibilityExpandedStatus` once per element and threads the value through to both the stored field and the description builder. - Description appends "Expanded." / "Collapsed." as a trait specifier and appends "Double tap to collapse." / "Double tap to expand." to the existing hint, preserving any pre-existing hint text. - Localized strings for en, de, ru. - Demo view + snapshot test for `DisclosureGroup` with reference images on iOS 17.5, 18.5, 26.2. - Unit tests for description/hint append edge cases (including the no-trailing-period case). - Sync-invariant tests guarding the assumption that the public iOS 18 setter writes through to the private getter and that no private setter exists (gated on Xcode 16+ / iOS 18 SDK). Why the private getter and not the public iOS 18 API: The public `accessibilityExpandedStatus` setter syncs through to the private `_accessibilityExpandedStatus` getter on stock UIKit elements, so reading the private getter sees everything the public API sees. SwiftUI's `AccessibilityNode` overrides only the private getter, so the public getter returns `0` for every SwiftUI element. The private getter is therefore the union of both worlds — the single source of truth that VoiceOver itself reads. Filed an Apple bug repro at https://github.com/RoyalPineapple/SwiftUIExpandedStatusBug. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ca3c742 to
d84abd4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds support for reading the expanded/collapsed state from any accessibility element that exposes one — including UIKit elements that adopt iOS 18's
accessibilityExpandedStatusand SwiftUIDisclosureGroup/ expandable list sections. Accessibility snapshots now show whether these elements are expanded or collapsed, matching what VoiceOver announces to users.What changed
ExpandedStatusenum onAccessibilityElement—unsupported,expanded,collapsed_accessibilityExpandedStatusvia the ObjC runtime on each accessibility element during parsing — works for both UIKit and SwiftUI sources"Section. Button. Heading. Expanded.""Double tap to collapse."/"Double tap to expand.", appended to any existing hintDisclosureGroupwith reference images on iOS 17.5, 18.5, 26.2Why read the private getter (and not the public iOS 18 API)
We chose
_accessibilityExpandedStatusdeliberately — it's the single source of truth that covers every element type that exposes expanded state, while the publicaccessibilityExpandedStatusonly covers a subset:NSObject_accessibilityExpandedStatus(getter) andaccessibilityExpandedStatus(getter/setter) are both declared directly onNSObjectUIView/NSObject, setting the public property always updates the private getter — they share backing storage, so reading the private getter captures everything the public API captures_setAccessibilityExpandedStatus:does not exist on any class; the private key is also not KVC-compliantSwiftUI.AccessibilityNodeoverrides_accessibilityExpandedStatusto read from SwiftUI's internal accessibility graph, bypassing the shared storage entirely. The public getter returns0for all SwiftUI nodes — this appears to be a SwiftUI bug, with a standalone repro filed at RoyalPineapple/SwiftUIExpandedStatusBug_accessibilityExpandedStatusreturns — confirmed on-device with iPhone 15Conclusion: The private getter is the union of both worlds — it returns the correct value for UIKit elements (because the public setter syncs into it) and for SwiftUI elements (because SwiftUI overrides it). Reading the public API alone would miss every SwiftUI element. We use
responds(to:)+method(for:)to read the value, consistent with how the codebase already reads other private accessibility properties.Data flow
flowchart LR U["UIKit element\n(public API set)"] -->|"backing storage"| B S["SwiftUI AccessibilityNode\n(overrides private getter)"] -->|"override"| B B["_accessibilityExpandedStatus\n(read via NSObject extension)"] B -->|"ExpandedStatus enum"| C["AccessibilityElement\n.expandedStatus"] B -->|"rawStatus 1/2"| D["Description: 'Expanded.' / 'Collapsed.'"] B -->|"rawStatus 1/2"| E["Hint: 'Double tap to collapse/expand.'"] D --> F["Snapshot Legend"] E --> FSnapshot output
Before:
After:
Related
Test plan
SwiftUIDisclosureGroupTestssnapshot test passes on iOS 17.5, 18.5, 26.2