Skip to content

Commit fd625d2

Browse files
Add hierarchical accessibility parsing API with container visualization
This adds: - ContainerType enum (list, landmark, dataTable, semanticGroup) - ContainerInfo struct for container metadata - AccessibilityHierarchyNode enum for tree structure representation - parseAccessibilityHierarchy() method returning hierarchical structure - Hierarchy utilities for flattening and traversal The hierarchical API preserves parent-child relationships and container semantics, enabling container visualization in snapshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent dc50558 commit fd625d2

1 file changed

Lines changed: 331 additions & 1 deletion

File tree

Sources/AccessibilitySnapshot/Parser/Swift/Classes/AccessibilityHierarchyParser.swift

Lines changed: 331 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,76 @@ public struct AccessibilityMarker: Equatable {
153153

154154
}
155155

156+
// MARK: - Container Visualization Types
157+
158+
/// Types of accessibility containers
159+
public enum ContainerType: String, Equatable {
160+
case none
161+
case list
162+
case landmark
163+
case dataTable
164+
case semanticGroup
165+
}
166+
167+
/// Information about a container node
168+
public struct ContainerInfo: Equatable {
169+
/// The type of container
170+
public let type: ContainerType
171+
172+
/// Container's accessibility label (if any)
173+
public let label: String?
174+
175+
/// Container's accessibility value (if any)
176+
public let value: String?
177+
178+
/// Container's accessibility identifier (if any)
179+
public let identifier: String?
180+
181+
/// Container's frame in the root view's coordinate space (for visualization)
182+
public let frame: CGRect
183+
184+
public init(type: ContainerType, label: String?, value: String?, identifier: String?, frame: CGRect) {
185+
self.type = type
186+
self.label = label
187+
self.value = value
188+
self.identifier = identifier
189+
self.frame = frame
190+
}
191+
}
192+
193+
/// A node in the accessibility hierarchy tree
194+
public enum AccessibilityHierarchyNode: Equatable {
195+
/// A leaf node representing an accessibility element
196+
/// - marker: The accessibility marker for this element
197+
/// - traversalIndex: Position in VoiceOver traversal order
198+
case element(marker: AccessibilityMarker, traversalIndex: Int)
199+
200+
/// A container node that groups child elements
201+
/// - info: Container metadata (type, label, value, identifier, frame)
202+
/// - children: Child nodes within this container
203+
case container(info: ContainerInfo, children: [AccessibilityHierarchyNode])
204+
205+
/// Child nodes (empty for leaf elements, contains children for containers)
206+
public var children: [AccessibilityHierarchyNode] {
207+
switch self {
208+
case .element:
209+
return []
210+
case .container(_, let children):
211+
return children
212+
}
213+
}
214+
215+
/// Position in VoiceOver traversal order (nil for container-only nodes)
216+
public var traversalIndex: Int? {
217+
switch self {
218+
case .element(_, let index):
219+
return index
220+
case .container:
221+
return nil
222+
}
223+
}
224+
}
225+
156226
// MARK: -
157227

158228
public protocol UserInterfaceLayoutDirectionProviding {
@@ -166,7 +236,7 @@ extension UIApplication: UserInterfaceLayoutDirectionProviding {}
166236
public protocol UserInterfaceIdiomProviding {
167237

168238
var userInterfaceIdiom: UIUserInterfaceIdiom { get }
169-
239+
170240
}
171241

172242
extension UIDevice: UserInterfaceIdiomProviding {}
@@ -325,6 +395,42 @@ public final class AccessibilityHierarchyParser {
325395
}
326396
}
327397

398+
/// Parses the accessibility hierarchy starting from the `root` view and returns a tree structure
399+
/// preserving parent-child relationships and container semantics.
400+
///
401+
/// The returned hierarchy uses smart container flattening: containers are only explicit nodes when they
402+
/// have meaningful properties (label, value, identifier) or provide important semantic context
403+
/// (list, landmark, data table).
404+
///
405+
/// Each element node includes a `traversalIndex` indicating its position in VoiceOver's navigation order.
406+
///
407+
/// - parameter root: The root view of the accessibility hierarchy
408+
/// - parameter rotorResultLimit: Maximum number of rotor results to collect in each direction. Defaults to 10.
409+
/// - parameter userInterfaceLayoutDirectionProvider: Provider of the device's UI layout direction
410+
/// - parameter userInterfaceIdiomProvider: Provider of the device's interface idiom
411+
/// - returns: Array of root-level hierarchy nodes (typically one node for the root view's hierarchy)
412+
public func parseAccessibilityHierarchy(
413+
in root: UIView,
414+
rotorResultLimit: Int = AccessibilityMarker.defaultRotorResultLimit,
415+
userInterfaceLayoutDirectionProvider: UserInterfaceLayoutDirectionProviding = UIApplication.shared,
416+
userInterfaceIdiomProvider: UserInterfaceIdiomProviding = UIDevice.current
417+
) -> [AccessibilityHierarchyNode] {
418+
let userInterfaceLayoutDirection = userInterfaceLayoutDirectionProvider.userInterfaceLayoutDirection
419+
let userInterfaceIdiom = userInterfaceIdiomProvider.userInterfaceIdiom
420+
421+
let accessibilityNodes = root.recursiveAccessibilityHierarchy()
422+
423+
let (hierarchy, _) = buildHierarchy(
424+
from: accessibilityNodes,
425+
in: root,
426+
rotorResultLimit: rotorResultLimit,
427+
userInterfaceLayoutDirection: userInterfaceLayoutDirection,
428+
userInterfaceIdiom: userInterfaceIdiom
429+
)
430+
431+
return hierarchy
432+
}
433+
328434
// MARK: - Private Types
329435

330436
/// Representation of an accessibility element, made up of the element `object` itself and the `context` in which it
@@ -613,6 +719,170 @@ public final class AccessibilityHierarchyParser {
613719
/// Used for memoization of accessibility hierarchy parsing when determining element contexts.
614720
private var viewToElementsMap: [UIView: [NSObject]] = [:]
615721

722+
// MARK: - Private Hierarchy Methods
723+
724+
/// Creates ContainerInfo for a container object if it should be represented as an explicit container node
725+
private func containerInfo(
726+
for object: NSObject,
727+
containerType: ContainerType,
728+
in root: UIView
729+
) -> ContainerInfo? {
730+
let label = object.accessibilityLabel
731+
let value = object.accessibilityValue
732+
let identifier = object.identifier
733+
734+
// Get container's frame in root coordinate space
735+
let frame: CGRect
736+
if let view = object as? UIView {
737+
frame = root.convert(view.bounds, from: view)
738+
} else {
739+
frame = root.convert(object.accessibilityFrame, from: nil)
740+
}
741+
742+
// Always keep data tables (complex structure)
743+
if containerType == .dataTable {
744+
return ContainerInfo(type: containerType, label: label, value: value, identifier: identifier, frame: frame)
745+
}
746+
747+
// Keep container if it has meaningful properties
748+
if label != nil || value != nil || identifier != nil {
749+
return ContainerInfo(type: containerType, label: label, value: value, identifier: identifier, frame: frame)
750+
}
751+
752+
// Keep list/landmark containers for semantic meaning
753+
if containerType == .list || containerType == .landmark {
754+
return ContainerInfo(type: containerType, label: label, value: value, identifier: identifier, frame: frame)
755+
}
756+
757+
// Flatten semantic groups without properties
758+
return nil
759+
}
760+
761+
/// Recursively builds the accessibility hierarchy from internal AccessibilityNode tree
762+
/// Returns tuple of (hierarchy nodes, next traversal index)
763+
private func buildHierarchy(
764+
from nodes: [AccessibilityNode],
765+
in root: UIView,
766+
rotorResultLimit: Int,
767+
userInterfaceLayoutDirection: UIUserInterfaceLayoutDirection,
768+
userInterfaceIdiom: UIUserInterfaceIdiom,
769+
startingTraversalIndex: Int = 0
770+
) -> ([AccessibilityHierarchyNode], Int) {
771+
var hierarchyNodes: [AccessibilityHierarchyNode] = []
772+
var currentTraversalIndex = startingTraversalIndex
773+
774+
// First, sort elements to get proper traversal order
775+
let sortedElementList = sortedElements(
776+
for: nodes,
777+
explicitlyOrdered: false,
778+
in: root,
779+
userInterfaceLayoutDirection: userInterfaceLayoutDirection,
780+
userInterfaceIdiom: userInterfaceIdiom
781+
)
782+
783+
// Create context for each element
784+
let contextualized = sortedElementList.map { element in
785+
return ContextualElement(
786+
object: element.object,
787+
context: context(
788+
for: element.object,
789+
from: element.contextProvider,
790+
userInterfaceLayoutDirection: userInterfaceLayoutDirection,
791+
userInterfaceIdiom: userInterfaceIdiom
792+
)
793+
)
794+
}
795+
796+
// Build markers for all elements
797+
let markers = contextualized.map { element -> AccessibilityMarker in
798+
let (description, hint) = element.object.accessibilityDescription(context: element.context)
799+
let activationPoint = element.object.accessibilityActivationPoint
800+
801+
return AccessibilityMarker(
802+
description: description,
803+
label: element.object.accessibilityLabel,
804+
value: element.object.accessibilityValue,
805+
traits: element.object.accessibilityTraits,
806+
identifier: element.object.identifier,
807+
hint: hint,
808+
userInputLabels: element.object.accessibilityUserInputLabels,
809+
shape: Self.accessibilityShape(for: element.object, in: root),
810+
activationPoint: root.convert(activationPoint, from: nil),
811+
usesDefaultActivationPoint: activationPoint.approximatelyEquals(
812+
Self.defaultActivationPoint(for: element.object),
813+
tolerance: 1 / (root.window?.screen ?? UIScreen.main).scale
814+
),
815+
customActions: element.object.accessibilityCustomActions?.map { $0.name } ?? [],
816+
customContent: element.object.customContent,
817+
customRotors: element.object.customRotors(in: root, context: element.context, resultLimit: rotorResultLimit),
818+
accessibilityLanguage: element.object.accessibilityLanguage,
819+
respondsToUserInteraction: element.object.accessibilityRespondsToUserInteraction
820+
)
821+
}
822+
823+
// Now process nodes recursively to build hierarchy
824+
func processNodes(_ nodes: [AccessibilityNode]) -> [AccessibilityHierarchyNode] {
825+
var result: [AccessibilityHierarchyNode] = []
826+
827+
for node in nodes {
828+
switch node {
829+
case .element(let object, _):
830+
// Find this element's marker
831+
if let index = sortedElementList.firstIndex(where: { $0.object === object }),
832+
index < markers.count {
833+
let marker = markers[index]
834+
let hierarchyNode = AccessibilityHierarchyNode.element(
835+
marker: marker,
836+
traversalIndex: currentTraversalIndex
837+
)
838+
result.append(hierarchyNode)
839+
currentTraversalIndex += 1
840+
}
841+
842+
case .group(let children, _, let frameProvider):
843+
// Determine container type and check if it should be explicit
844+
var containerType: ContainerType? = nil
845+
var shouldBeContainer = false
846+
847+
if let view = frameProvider as? UIView {
848+
if view.accessibilityContainerType == .list {
849+
containerType = .list
850+
} else if view.accessibilityContainerType == .landmark {
851+
containerType = .landmark
852+
} else if view.accessibilityContainerType == .dataTable {
853+
containerType = .dataTable
854+
} else if view.shouldGroupAccessibilityChildren {
855+
containerType = .semanticGroup
856+
}
857+
858+
if let type = containerType {
859+
if let info = containerInfo(for: view, containerType: type, in: root) {
860+
// Create container node with children
861+
let childNodes = processNodes(children)
862+
let containerNode = AccessibilityHierarchyNode.container(
863+
info: info,
864+
children: childNodes
865+
)
866+
result.append(containerNode)
867+
shouldBeContainer = true
868+
}
869+
}
870+
}
871+
872+
// If not a container, flatten children
873+
if !shouldBeContainer {
874+
result.append(contentsOf: processNodes(children))
875+
}
876+
}
877+
}
878+
879+
return result
880+
}
881+
882+
hierarchyNodes = processNodes(nodes)
883+
return (hierarchyNodes, currentTraversalIndex)
884+
}
885+
616886
}
617887

618888
fileprivate extension AccessibilityHierarchyParser {
@@ -948,3 +1218,63 @@ internal extension UITextInput {
9481218
}
9491219
}
9501220
}
1221+
1222+
// MARK: - Hierarchy Utilities
1223+
1224+
extension AccessibilityHierarchyNode {
1225+
1226+
/// Flattens the hierarchy tree into an array of markers in VoiceOver traversal order
1227+
public func flattenToMarkers() -> [AccessibilityMarker] {
1228+
var markers: [(index: Int, marker: AccessibilityMarker)] = []
1229+
1230+
func collectMarkers(from node: AccessibilityHierarchyNode) {
1231+
switch node {
1232+
case .element(let marker, let index):
1233+
markers.append((index, marker))
1234+
case .container(_, let children):
1235+
// Containers don't have markers, just traverse children
1236+
for child in children {
1237+
collectMarkers(from: child)
1238+
}
1239+
}
1240+
}
1241+
1242+
collectMarkers(from: self)
1243+
1244+
// Sort by traversal index and return markers
1245+
return markers.sorted { $0.index < $1.index }.map { $0.marker }
1246+
}
1247+
1248+
/// Recursively traverses the hierarchy tree, calling the visitor for each node
1249+
public func traverse(_ visitor: (AccessibilityHierarchyNode) -> Void) {
1250+
visitor(self)
1251+
for child in children {
1252+
child.traverse(visitor)
1253+
}
1254+
}
1255+
}
1256+
1257+
extension Array where Element == AccessibilityHierarchyNode {
1258+
1259+
/// Flattens an array of hierarchy nodes into a single array of markers in VoiceOver traversal order
1260+
public func flattenToMarkers() -> [AccessibilityMarker] {
1261+
var allMarkers: [(index: Int, marker: AccessibilityMarker)] = []
1262+
1263+
for node in self {
1264+
func collectMarkers(from node: AccessibilityHierarchyNode) {
1265+
switch node {
1266+
case .element(let marker, let index):
1267+
allMarkers.append((index, marker))
1268+
case .container(_, let children):
1269+
for child in children {
1270+
collectMarkers(from: child)
1271+
}
1272+
}
1273+
}
1274+
1275+
collectMarkers(from: node)
1276+
}
1277+
1278+
return allMarkers.sorted { $0.index < $1.index }.map { $0.marker }
1279+
}
1280+
}

0 commit comments

Comments
 (0)