@@ -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
158228public protocol UserInterfaceLayoutDirectionProviding {
@@ -166,7 +236,7 @@ extension UIApplication: UserInterfaceLayoutDirectionProviding {}
166236public protocol UserInterfaceIdiomProviding {
167237
168238 var userInterfaceIdiom : UIUserInterfaceIdiom { get }
169-
239+
170240}
171241
172242extension 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
618888fileprivate 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