Skip to content

Add expanded/collapsed state support for SwiftUI DisclosureGroup#324

Draft
RoyalPineapple wants to merge 6 commits into
mainfrom
RoyalPineapple/expanded-status
Draft

Add expanded/collapsed state support for SwiftUI DisclosureGroup#324
RoyalPineapple wants to merge 6 commits into
mainfrom
RoyalPineapple/expanded-status

Conversation

@RoyalPineapple

@RoyalPineapple RoyalPineapple commented Mar 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

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. 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 — works for both UIKit and SwiftUI sources
  • 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 read the private getter (and not the public iOS 18 API)

We chose _accessibilityExpandedStatus deliberately — it's the single source of truth that covers every element type that exposes expanded state, while the public accessibilityExpandedStatus only covers a subset:

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, so reading the private getter captures everything the public API captures
No private setter exists _setAccessibilityExpandedStatus: does not exist on any class; the private key is also not KVC-compliant
SwiftUI overrides only the private getter SwiftUI.AccessibilityNode overrides _accessibilityExpandedStatus to read from SwiftUI's internal accessibility graph, bypassing the shared storage entirely. The public getter returns 0 for all SwiftUI nodes — this appears to be a SwiftUI bug, with a standalone repro filed at RoyalPineapple/SwiftUIExpandedStatusBug
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: 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 --> F
Loading

Snapshot output

Before:

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

After:

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

Related

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 UIKit
  • 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
@RoyalPineapple RoyalPineapple force-pushed the RoyalPineapple/expanded-status branch from e9cb098 to 6ad2488 Compare April 7, 2026 15:19
@RoyalPineapple RoyalPineapple force-pushed the RoyalPineapple/expanded-status branch from aaaee9e to 201c36b Compare April 30, 2026 17:18
@RoyalPineapple RoyalPineapple marked this pull request as ready for review May 2, 2026 16:13
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>
@RoyalPineapple RoyalPineapple force-pushed the RoyalPineapple/expanded-status branch from ca3c742 to d84abd4 Compare May 5, 2026 11:14
@RoyalPineapple RoyalPineapple marked this pull request as draft May 28, 2026 14:21
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