Skip to content

Commit 7ca0b7b

Browse files
Add a generic fold to AccessibilityHierarchyParser
Introduce parseAccessibilityHierarchy<Node>(in:makeElement:makeContainer:), a single bottom-up fold that builds a caller-defined node type in one traversal. makeElement receives the parsed AccessibilityElement, its traversalIndex, and the source accessibility object; makeContainer receives the AccessibilityContainer, its already-built children, and the source object. parseAccessibilityHierarchy(in:) and parseAccessibilityElements(in:) become the default instantiation producing [AccessibilityHierarchy] and ignoring the source.
1 parent 2b4eea1 commit 7ca0b7b

2 files changed

Lines changed: 98 additions & 8 deletions

File tree

‎Example/UnitTests/AccessibilityHierarchyParserTests.swift‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1227,6 +1227,45 @@ final class AccessibilityHierarchyParserTests: XCTestCase {
12271227
XCTAssertEqual(elements, ["emptyPath"], "Element with empty accessibility path should still be parsed")
12281228
}
12291229

1230+
// MARK: - Generic Fold Tests
1231+
1232+
func testGenericFoldPassesSourceObjects() {
1233+
let rootView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100))
1234+
1235+
let container = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100))
1236+
container.accessibilityContainerType = .list
1237+
rootView.addSubview(container)
1238+
1239+
let element = UIView(frame: .init(x: 10, y: 10, width: 30, height: 30))
1240+
element.isAccessibilityElement = true
1241+
element.accessibilityLabel = "Item"
1242+
element.accessibilityFrame = CGRect(x: 10, y: 10, width: 30, height: 30)
1243+
container.addSubview(element)
1244+
1245+
var capturedElementSource: NSObject?
1246+
var capturedContainerSource: NSObject?
1247+
1248+
let parser = AccessibilityHierarchyParser()
1249+
let _: [String] = parser.parseAccessibilityHierarchy(
1250+
in: rootView,
1251+
userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(
1252+
userInterfaceLayoutDirection: .leftToRight
1253+
),
1254+
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone),
1255+
makeElement: { elem, index, source in
1256+
capturedElementSource = source
1257+
return "element:\(elem.description)"
1258+
},
1259+
makeContainer: { cont, children, source in
1260+
capturedContainerSource = source
1261+
return "container:[\(children.joined(separator: ","))]"
1262+
}
1263+
)
1264+
1265+
XCTAssertTrue(capturedElementSource === element)
1266+
XCTAssertTrue(capturedContainerSource === container)
1267+
}
1268+
12301269
// MARK: - Private Helpers
12311270

12321271
private func parseMarkers(in view: UIView) -> [AccessibilityMarker] {

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

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,41 @@ public final class AccessibilityHierarchyParser {
163163
userInterfaceLayoutDirectionProvider: UserInterfaceLayoutDirectionProviding = UIApplication.shared,
164164
userInterfaceIdiomProvider: UserInterfaceIdiomProviding = UIDevice.current
165165
) -> [AccessibilityHierarchy] {
166+
parseAccessibilityHierarchy(
167+
in: root,
168+
rotorResultLimit: rotorResultLimit,
169+
userInterfaceLayoutDirectionProvider: userInterfaceLayoutDirectionProvider,
170+
userInterfaceIdiomProvider: userInterfaceIdiomProvider,
171+
makeElement: { element, traversalIndex, _ in .element(element, traversalIndex: traversalIndex) },
172+
makeContainer: { container, children, _ in .container(container, children: children) }
173+
)
174+
}
175+
176+
/// Parses the accessibility hierarchy and folds it into a caller-defined node type.
177+
///
178+
/// Walks the accessibility tree once in VoiceOver traversal order and builds a tree of `Node`
179+
/// values bottom-up. `makeElement` constructs a leaf from its parsed `AccessibilityElement`, its
180+
/// `traversalIndex`, and the live accessibility object that produced it. `makeContainer`
181+
/// constructs an interior node from its `AccessibilityContainer`, its already-built child nodes
182+
/// in traversal order, and the source object backing the container.
183+
///
184+
/// The `source` parameters expose the originating accessibility object so callers can correlate
185+
/// parsed markers back to their source views — useful for test harnesses, debugging overlays,
186+
/// and custom renderers. `parseAccessibilityHierarchy(in:)` is the default instantiation,
187+
/// producing `[AccessibilityHierarchy]` and ignoring the source.
188+
///
189+
/// Both closures are invoked synchronously during the walk, on the caller's thread.
190+
///
191+
/// - parameter makeElement: Builds a leaf node. Called once per element, in traversal order.
192+
/// - parameter makeContainer: Builds an interior node from its children. Called once per container.
193+
public func parseAccessibilityHierarchy<Node>(
194+
in root: UIView,
195+
rotorResultLimit: Int = AccessibilityElement.defaultRotorResultLimit,
196+
userInterfaceLayoutDirectionProvider: UserInterfaceLayoutDirectionProviding = UIApplication.shared,
197+
userInterfaceIdiomProvider: UserInterfaceIdiomProviding = UIDevice.current,
198+
makeElement: (AccessibilityElement, _ traversalIndex: Int, _ source: NSObject) -> Node,
199+
makeContainer: (AccessibilityContainer, _ children: [Node], _ source: NSObject) -> Node
200+
) -> [Node] {
166201
let userInterfaceLayoutDirection = userInterfaceLayoutDirectionProvider.userInterfaceLayoutDirection
167202
let userInterfaceIdiom = userInterfaceIdiomProvider.userInterfaceIdiom
168203

@@ -194,7 +229,14 @@ public final class AccessibilityHierarchyParser {
194229
buildElement(from: element.object, context: element.context, in: root, rotorResultLimit: rotorResultLimit)
195230
}
196231

197-
return mapNodesToHierarchy(accessibilityNodes, sortedElements: uncontextualizedElements, elements: elements, in: root)
232+
return foldNodes(
233+
accessibilityNodes,
234+
sortedElements: uncontextualizedElements,
235+
elements: elements,
236+
in: root,
237+
makeElement: makeElement,
238+
makeContainer: makeContainer
239+
)
198240
}
199241

200242
// MARK: - Private Types
@@ -531,23 +573,31 @@ public final class AccessibilityHierarchyParser {
531573

532574
// MARK: - Private Hierarchy Methods
533575

534-
private func mapNodesToHierarchy(
576+
/// Folds the structural accessibility tree into a caller-defined `Node` type, bottom-up.
577+
///
578+
/// Each produced node is paired internally with a traversal-derived sort key so that a
579+
/// container can order its children without inspecting the opaque `Node`. This mirrors
580+
/// `AccessibilityHierarchy.sortIndex`: an element sorts by its `traversalIndex`, a container by
581+
/// the minimum sort key among its children.
582+
private func foldNodes<Node>(
535583
_ nodes: [AccessibilityNode],
536584
sortedElements: [(object: NSObject, contextProvider: ContextProvider?)],
537585
elements: [AccessibilityElement],
538-
in root: UIView
539-
) -> [AccessibilityHierarchy] {
586+
in root: UIView,
587+
makeElement: (AccessibilityElement, _ traversalIndex: Int, _ source: NSObject) -> Node,
588+
makeContainer: (AccessibilityContainer, _ children: [Node], _ source: NSObject) -> Node
589+
) -> [Node] {
540590
var indexLookup: [ObjectIdentifier: Int] = [:]
541591
for (index, element) in sortedElements.enumerated() {
542592
indexLookup[ObjectIdentifier(element.object)] = index
543593
}
544594

545-
func mapNode(_ node: AccessibilityNode) -> [AccessibilityHierarchy] {
595+
func mapNode(_ node: AccessibilityNode) -> [(node: Node, sortIndex: Int)] {
546596
switch node {
547597
case let .element(object, _):
548598
guard let index = indexLookup[ObjectIdentifier(object)],
549599
index < elements.count else { return [] }
550-
return [.element(elements[index], traversalIndex: index)]
600+
return [(makeElement(elements[index], index, object), index)]
551601

552602
case let .group(children, _, _, containerInfo):
553603
let mappedChildren = children.flatMap { mapNode($0) }.sorted { lhs, rhs in
@@ -581,14 +631,15 @@ public final class AccessibilityHierarchyParser {
581631
type: containerType,
582632
frame: frame
583633
)
584-
return [.container(container, children: mappedChildren)]
634+
let sortIndex = mappedChildren.map { $0.sortIndex }.min() ?? Int.max
635+
return [(makeContainer(container, mappedChildren.map { $0.node }, info.view), sortIndex)]
585636
}
586637

587638
return mappedChildren
588639
}
589640
}
590641

591-
return nodes.flatMap { mapNode($0) }
642+
return nodes.flatMap { mapNode($0) }.map { $0.node }
592643
}
593644
}
594645

0 commit comments

Comments
 (0)