Skip to content

Commit f5e9f91

Browse files
Add hierarchical accessibility parsing API
Introduces `parseAccessibilityHierarchy()` which returns accessibility data as a tree structure preserving container relationships, compared to the existing `parseAccessibilityElements()` which returns a flat list. New types: - `AccessibilityHierarchy`: Recursive enum with `.element` and `.container` cases - `AccessibilityContainer`: Container metadata (type, frame, traits) - `ContainerType`: Enum for semantic groups, lists, landmarks, data tables, tab bars All types conform to Codable for serialization. The hierarchy can be flattened back to elements via `flattenToElements()` for compatibility. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7af99ef commit f5e9f91

8 files changed

Lines changed: 1634 additions & 215 deletions

File tree

51 Bytes
Loading

Example/UnitTests/AccessibilityHierarchyParserTests.swift

Lines changed: 868 additions & 2 deletions
Large diffs are not rendered by default.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import CoreGraphics
2+
3+
/// Information about a container node
4+
public struct AccessibilityContainer: Equatable, Codable {
5+
/// The type of accessibility container with its associated data
6+
public enum ContainerType: Equatable, Codable {
7+
/// A semantic grouping with optional label, value, and identifier
8+
case semanticGroup(label: String?, value: String?, identifier: String?)
9+
10+
/// A list container (affects rotor navigation)
11+
case list
12+
13+
/// A landmark container (affects rotor navigation)
14+
case landmark
15+
16+
/// A data table with row and column counts
17+
case dataTable(rowCount: Int, columnCount: Int)
18+
19+
/// A tab bar container (detected via .tabBar trait)
20+
case tabBar
21+
}
22+
23+
/// The type of container with its associated data
24+
public let type: ContainerType
25+
26+
/// Container's frame in the root view's coordinate space (for visualization)
27+
public let frame: CGRect
28+
29+
public init(type: ContainerType, frame: CGRect) {
30+
self.type = type
31+
self.frame = frame
32+
}
33+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import UIKit
2+
3+
/// A type alias for backwards compatibility.
4+
public typealias AccessibilityMarker = AccessibilityElement
5+
6+
public struct AccessibilityElement: Equatable, Codable {
7+
/// Default number of rotor results to collect in each direction.
8+
public static let defaultRotorResultLimit: Int = 10
9+
10+
// MARK: - Public Types
11+
12+
public enum Shape: Equatable {
13+
/// Accessibility frame, in the coordinate space of the view being snapshotted.
14+
case frame(CGRect)
15+
16+
/// Accessibility path, in the coordinate space of the view being snapshotted.
17+
case path(UIBezierPath)
18+
}
19+
20+
public struct CustomRotor: Equatable, CustomStringConvertible, Codable {
21+
public struct ResultMarker: Equatable, CustomStringConvertible, Codable {
22+
public let elementDescription: String
23+
public let rangeDescription: String?
24+
public let shape: Shape?
25+
26+
public var description: String {
27+
guard let rangeDescription else {
28+
return elementDescription
29+
}
30+
return "\(elementDescription) \(rangeDescription)"
31+
}
32+
}
33+
34+
public var name: String
35+
public var resultMarkers: [AccessibilityElement.CustomRotor.ResultMarker] = []
36+
public let limit: UIAccessibilityCustomRotor.CollectedRotorResults.Limit
37+
38+
init?(from: UIAccessibilityCustomRotor, parentElement: NSObject, root: UIView, context: AccessibilityHierarchyParser.Context? = nil, resultLimit: Int) {
39+
guard from.isKnownRotorType else { return nil }
40+
name = from.displayName(locale: parentElement.accessibilityLanguage)
41+
let collected = from.collectAllResults(nextLimit: resultLimit, previousLimit: resultLimit)
42+
limit = collected.limit
43+
resultMarkers = collected.results.compactMap { result in
44+
guard let element = result.targetElement as? NSObject else { return nil }
45+
var description = element.accessibilityDescription(context: context).description
46+
var shape: Shape? = AccessibilityHierarchyParser.accessibilityShape(for: element, in: root)
47+
48+
if let range = result.targetRange,
49+
let input = element as? UITextInput
50+
{
51+
if let path = input.accessibilityPath(for: range) {
52+
let converted = root.convert(path, from: input as? UIView)
53+
shape = .path(converted)
54+
}
55+
if let substring = input.text(in: range) {
56+
description = substring
57+
}
58+
return ResultMarker(elementDescription: description, rangeDescription: range.formatted(in: input), shape: shape)
59+
}
60+
return ResultMarker(elementDescription: description, rangeDescription: nil, shape: shape)
61+
}
62+
}
63+
64+
public var description: String {
65+
return name + ": " + resultMarkers.map { $0.description }.joined(separator: "\n")
66+
}
67+
}
68+
69+
public struct CustomContent: Codable, Equatable {
70+
public var label: String
71+
public var value: String
72+
public var isImportant: Bool
73+
74+
@available(iOS 14.0, *)
75+
init(from: AXCustomContent) {
76+
label = from.label
77+
value = from.value
78+
isImportant = from.importance == .high
79+
}
80+
}
81+
82+
public struct CustomAction: Equatable, Codable {
83+
public var name: String
84+
public var image: UIImage?
85+
86+
init(name: String, image: UIImage?) {
87+
self.name = name
88+
self.image = image
89+
}
90+
91+
@available(iOS 14.0, *)
92+
init(from: UIAccessibilityCustomAction) {
93+
name = from.name
94+
image = from.image
95+
}
96+
97+
private enum CodingKeys: String, CodingKey {
98+
case name
99+
case imageData
100+
case imageScale
101+
}
102+
103+
public init(from decoder: Decoder) throws {
104+
let container = try decoder.container(keyedBy: CodingKeys.self)
105+
name = try container.decode(String.self, forKey: .name)
106+
107+
if let imageData = try container.decodeIfPresent(Data.self, forKey: .imageData) {
108+
let scale = try container.decodeIfPresent(CGFloat.self, forKey: .imageScale) ?? 1.0
109+
image = UIImage(data: imageData, scale: scale)
110+
} else {
111+
image = nil
112+
}
113+
}
114+
115+
public func encode(to encoder: Encoder) throws {
116+
var container = encoder.container(keyedBy: CodingKeys.self)
117+
try container.encode(name, forKey: .name)
118+
119+
if let image = image, let pngData = image.pngData() {
120+
try container.encode(pngData, forKey: .imageData)
121+
try container.encode(image.scale, forKey: .imageScale)
122+
}
123+
}
124+
}
125+
126+
// MARK: - Public Properties
127+
128+
/// The description of the accessibility element that will be read by VoiceOver when the element is brought into
129+
/// focus.
130+
public let description: String
131+
132+
public let label: String?
133+
134+
public let value: String?
135+
136+
public let traits: UIAccessibilityTraits
137+
138+
/// A unique identifier for the element, primarily used in UI tests for locating and interacting with elements.
139+
/// This identifier is not visible to users.
140+
public let identifier: String?
141+
142+
/// A hint that will be read by VoiceOver if focus remains on the element after the `description` is read.
143+
public let hint: String?
144+
145+
/// The labels that will be used by Voice Control for user input.
146+
/// These labels are displayed based on the `AccessibilityContentDisplayMode` configuration:
147+
/// - `.always`: Always shows user input labels
148+
/// - `.whenOverridden`: Shows labels only when they differ from default values (future enhancement)
149+
/// - `.never`: Never shows user input labels
150+
public let userInputLabels: [String]?
151+
152+
/// The shape that will be highlighted on screen while the element is in focus.
153+
public let shape: Shape
154+
155+
/// The accessibility activation point, in the coordinate space of the view being snapshotted.
156+
public let activationPoint: CGPoint
157+
158+
/// Whether or not the `activationPoint` is the default activation point for the object.
159+
///
160+
/// For most elements, the default activation point is the midpoint of the element's accessibility frame. Certain
161+
/// elements have distinct defaults - for example, a `UISlider` puts its activation point at the center of its thumb
162+
/// by default.
163+
public let usesDefaultActivationPoint: Bool
164+
165+
/// The custom actions supported by the element.
166+
public let customActions: [CustomAction]
167+
168+
/// Any custom content included by the element.
169+
public let customContent: [CustomContent]
170+
171+
/// Any custom rotors included by the element.
172+
public let customRotors: [CustomRotor]
173+
174+
/// The language code of the language used to localize strings in the description.
175+
public let accessibilityLanguage: String?
176+
177+
/// Whether the element performs an action based on user interaction.
178+
public let respondsToUserInteraction: Bool
179+
180+
// MARK: - Initialization
181+
182+
init(
183+
description: String,
184+
label: String?,
185+
value: String?,
186+
traits: UIAccessibilityTraits,
187+
identifier: String?,
188+
hint: String?,
189+
userInputLabels: [String]?,
190+
shape: Shape,
191+
activationPoint: CGPoint,
192+
usesDefaultActivationPoint: Bool,
193+
customActions: [CustomAction],
194+
customContent: [CustomContent],
195+
customRotors: [CustomRotor],
196+
accessibilityLanguage: String?,
197+
respondsToUserInteraction: Bool
198+
) {
199+
self.description = description
200+
self.label = label
201+
self.value = value
202+
self.traits = traits
203+
self.identifier = identifier
204+
self.hint = hint
205+
self.userInputLabels = userInputLabels
206+
self.shape = shape
207+
self.activationPoint = activationPoint
208+
self.usesDefaultActivationPoint = usesDefaultActivationPoint
209+
self.customActions = customActions
210+
self.customContent = customContent
211+
self.customRotors = customRotors
212+
self.accessibilityLanguage = accessibilityLanguage
213+
self.respondsToUserInteraction = respondsToUserInteraction
214+
}
215+
}

0 commit comments

Comments
 (0)