Skip to content

Add expanded/collapsed state support for SwiftUI DisclosureGroup#324

Draft
RoyalPineapple wants to merge 11 commits intomainfrom
RoyalPineapple/expanded-status
Draft

Add expanded/collapsed state support for SwiftUI DisclosureGroup#324
RoyalPineapple wants to merge 11 commits intomainfrom
RoyalPineapple/expanded-status

Conversation

@RoyalPineapple
Copy link
Copy Markdown
Collaborator

@RoyalPineapple RoyalPineapple commented Mar 26, 2026

Summary

Adds support for reading the expanded/collapsed state from SwiftUI DisclosureGroup and expandable list sections, so accessibility snapshots now show whether these elements are expanded or collapsed — matching what VoiceOver announces to users.

What changed

  • New ExpandedStatus enum on AccessibilityElementunsupported, expanded, collapsed
  • Reads _accessibilityExpandedStatus via the ObjC runtime on each accessibility element during parsing
  • Appends status to VoiceOver description — e.g. "Section. Button. Heading. Expanded."
  • Appends hint"Double tap to collapse." / "Double tap to expand.", appended to any existing hint
  • Localized strings for en, de, ru
  • Demo view + snapshot test for DisclosureGroup with reference images on iOS 17.5, 18.5, 26.2

Why the private API

The public iOS 18 accessibilityExpandedStatus property exists but does not work on SwiftUI nodes — SwiftUI's AccessibilityNode overrides only the private _accessibilityExpandedStatus method. We verified this thoroughly through runtime introspection:

Finding Detail
Both APIs declared on NSObject _accessibilityExpandedStatus (getter) and accessibilityExpandedStatus (getter/setter) are both declared directly on NSObject
Public setter syncs to private getter On stock UIView/NSObject, setting the public property always updates the private getter — they share backing storage
No private setter exists _setAccessibilityExpandedStatus: does not exist on any class; the private key is also not KVC-compliant
Subclass override is the only way they diverge SwiftUI.AccessibilityNode overrides _accessibilityExpandedStatus to read from SwiftUI's internal accessibility graph, bypassing the shared storage entirely
VoiceOver reads the private getter When public and private values conflict, VoiceOver announces what _accessibilityExpandedStatus returns — confirmed on-device with iPhone 15
Rapid toggling never desyncs On stock views, pub/priv stay perfectly in sync across all permutations

Conclusion: Reading _accessibilityExpandedStatus is correct because it is the single source of truth that both SwiftUI (via subclass override) and UIKit (via the public setter's internal sync) funnel into. The public API is actually less reliable — it misses all SwiftUI elements entirely.

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
    A["SwiftUI DisclosureGroup"] -->|"_accessibilityExpandedStatus"| B["NSObject extension\n(runtime read)"]
    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 --> F
Loading

Snapshot output

Before:

Section Header. Button. Heading.
  Hint: (none)

After:

Section Header. Button. Heading. Expanded.
  Hint: Double tap to collapse.

Test plan

  • SwiftUIDisclosureGroupTests snapshot test passes on iOS 17.5, 18.5, 26.2
  • All existing snapshot tests unaffected
  • Build succeeds after rebase onto main
  • Runtime sync investigation: 12/12 tests pass confirming private API behavior
  • On-device VoiceOver verification with iPhone 15

@RoyalPineapple RoyalPineapple force-pushed the RoyalPineapple/expanded-status branch from 21ce7c6 to 2e59b92 Compare March 26, 2026 15:52
@RoyalPineapple RoyalPineapple changed the title Add _accessibilityExpandedStatus support to parser Add expanded/collapsed state support for SwiftUI DisclosureGroup Mar 26, 2026
Conductor and others added 5 commits April 7, 2026 17:18
SwiftUI DisclosureGroup and expandable list sections communicate their
expanded/collapsed state to VoiceOver through _accessibilityExpandedStatus,
but our parser didn't read this property. Snapshots for screens with
expandable content were missing this critical state information.

- Add ExpandedStatus enum (unsupported/expanded/collapsed) to AccessibilityMarker
- Read _accessibilityExpandedStatus via KVC on each accessibility element
- Include "Expanded." / "Collapsed." in the VoiceOver description text
- Add localized strings for en, de, ru
- Add SwiftUIDisclosureGroup demo view and snapshot test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
VoiceOver announces "Double tap to collapse/expand" as a hint on
DisclosureGroup elements. Include this hint in the snapshot output
alongside the existing Expanded/Collapsed trait specifier, appending
to any existing hint rather than overwriting it. Regenerate reference
images for all three OS versions (17.5, 18.5, 26.2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…d status findings

Adds interactive test UI for investigating _accessibilityExpandedStatus behavior
on real devices, and updates doc comments across the parser to reflect findings:
- _accessibilityExpandedStatus is the VoiceOver source of truth (since iOS 14.2)
- Public iOS 18 accessibilityExpandedStatus syncs TO private, not vice versa
- SwiftUI DisclosureGroup only sets the private API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@RoyalPineapple RoyalPineapple force-pushed the RoyalPineapple/expanded-status branch from e9cb098 to 6ad2488 Compare April 7, 2026 15:19
RoyalPineapple and others added 6 commits April 17, 2026 11:14
…Object KVC crash

- Restore SwiftUIDisclosureGroup.swift to its minimal form so it matches the
  committed reference images. Interactive mutation test UI was used for
  on-device investigation; findings are captured in commit messages and docs.
- testNSObjectExpandedStatusBehavior was crashing because plain NSObject is
  not KVC-compliant for _accessibilityExpandedStatus. Switch to method(for:)
  + unsafeBitCast to call the method directly without KVC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a PrivateAXSelector protocol + catalog that wraps every private API
call in a type-safe, crash-free invocation (uses method(for:) + unsafeBitCast
for primitives, perform() for objects — never KVC). NSObject.ax_private<S>
is the single entry point; each selector carries its return type.

Migrates the expanded-status read to this wrapper and removes the duplicated
inline KVC access in UIAccessibility+SnapshotAdditions.swift. The parser now
reads _accessibilityExpandedStatus once per element and threads the typed
ExpandedStatus through accessibilityDescription(context:expandedStatus:),
eliminating two additional reads and the latent NSUnknownKeyException risk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Captures three findings from the on-device investigation:
1. The private method is what VoiceOver actually reads.
2. The public iOS 18 getter has separate storage and misses SwiftUI overrides.
3. When the two disagree, VoiceOver announces based on the private value.

Also points the parser's read site to PrivateAX.ExpandedStatus for context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Deletes the 832-line ExpandedStatusDiagnosticTests.swift (pure print-based
investigation with few assertions), trims ExpandedStatusSyncTests.swift to
the four tests that guard real invariants, and adds
ExpandedStatusDescriptionTests.swift with direct string-level coverage of
the description/hint output — including the hint concatenation path.

Also drops the unused PrivateAXObjectSelector protocol and softens an
unsourced iOS 14.2 claim in the PrivateAX.ExpandedStatus doc comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The tests reference the public iOS 18 `accessibilityExpandedStatus` property,
which only exists in the iOS 18 SDK. The iOS 17 CI job runs on macOS-14 with
Xcode 15, which can't resolve the symbol at compile time. The tests are already
`@available(iOS 18.0, *)`, so gating the file with `#if compiler(>=6.0)` (same
pattern used in AccessibilityHierarchy+Codable.swift) is compile-only and
doesn't reduce coverage on runners that can actually execute them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Committed reference images for SwiftUIDisclosureGroupTests don't match
iOS 17.5 / 18.5 CI renders. Enable recordMode so CI writes fresh captures
to FB_REFERENCE_IMAGE_DIR; the failure-path "Reference Images" artifact
upload will then contain the correct references. Next commit will harvest
the images and flip recordMode off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant