Skip to content

Commit 465b976

Browse files
Fix false positive activation points for SwiftUI elements (#315)
* calculating effective frame for activation point default logic * Add unit tests for activation point default detection Test the four key scenarios: - Zero frame + zero activation point → treated as default - Zero frame + valid path + valid activation point → treated as default - Normal frame + center activation point → treated as default - Normal frame + custom activation point → not default Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cb336e7 commit 465b976

2 files changed

Lines changed: 143 additions & 5 deletions

File tree

Example/UnitTests/AccessibilityHierarchyParserTests.swift

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,106 @@ final class AccessibilityHierarchyParserTests: XCTestCase {
114114
// Now pad elements are sorted vertically and then left to right
115115
XCTAssertEqual(padAgain, ["C", "D", "B", "A"])
116116
}
117+
118+
// MARK: - Activation Point Default Detection
119+
120+
func testZeroFrameAndZeroActivationPointIsDefault() {
121+
let container = UIView(frame: .init(x: 0, y: 0, width: 400, height: 400))
122+
123+
let element = ActivationPointTestView(frame: .init(x: 10, y: 10, width: 100, height: 50))
124+
element.isAccessibilityElement = true
125+
element.accessibilityLabel = "Zero"
126+
element.overriddenFrame = .zero
127+
element.overriddenActivationPoint = .zero
128+
container.addSubview(element)
129+
130+
let markers = parseMarkers(in: container)
131+
XCTAssertEqual(markers.count, 1)
132+
XCTAssertTrue(markers[0].usesDefaultActivationPoint)
133+
}
134+
135+
func testZeroFrameWithPathAndValidActivationPointIsDefault() {
136+
let container = UIView(frame: .init(x: 0, y: 0, width: 400, height: 400))
137+
138+
let pathBounds = CGRect(x: 16, y: 16, width: 370, height: 48)
139+
let element = ActivationPointTestView(frame: .init(x: 10, y: 10, width: 370, height: 48))
140+
element.isAccessibilityElement = true
141+
element.accessibilityLabel = "PathElement"
142+
element.overriddenFrame = .zero
143+
element.overriddenPath = UIBezierPath(rect: pathBounds)
144+
element.overriddenActivationPoint = CGPoint(x: pathBounds.midX, y: pathBounds.midY)
145+
container.addSubview(element)
146+
147+
let markers = parseMarkers(in: container)
148+
XCTAssertEqual(markers.count, 1)
149+
XCTAssertTrue(markers[0].usesDefaultActivationPoint)
150+
}
151+
152+
func testNormalFrameWithCenterActivationPointIsDefault() {
153+
let container = UIView(frame: .init(x: 0, y: 0, width: 400, height: 400))
154+
155+
let frame = CGRect(x: 50, y: 50, width: 200, height: 60)
156+
let element = ActivationPointTestView(frame: frame)
157+
element.isAccessibilityElement = true
158+
element.accessibilityLabel = "Centered"
159+
element.overriddenFrame = frame
160+
element.overriddenActivationPoint = CGPoint(x: frame.midX, y: frame.midY)
161+
container.addSubview(element)
162+
163+
let markers = parseMarkers(in: container)
164+
XCTAssertEqual(markers.count, 1)
165+
XCTAssertTrue(markers[0].usesDefaultActivationPoint)
166+
}
167+
168+
func testNormalFrameWithCustomActivationPointIsNotDefault() {
169+
let container = UIView(frame: .init(x: 0, y: 0, width: 400, height: 400))
170+
171+
let frame = CGRect(x: 50, y: 50, width: 200, height: 60)
172+
let element = ActivationPointTestView(frame: frame)
173+
element.isAccessibilityElement = true
174+
element.accessibilityLabel = "Custom"
175+
element.overriddenFrame = frame
176+
element.overriddenActivationPoint = CGPoint(x: frame.maxX - 10, y: frame.midY)
177+
container.addSubview(element)
178+
179+
let markers = parseMarkers(in: container)
180+
XCTAssertEqual(markers.count, 1)
181+
XCTAssertFalse(markers[0].usesDefaultActivationPoint)
182+
}
183+
184+
// MARK: - Private Helpers
185+
186+
private func parseMarkers(in view: UIView) -> [AccessibilityMarker] {
187+
let parser = AccessibilityHierarchyParser()
188+
return parser.parseAccessibilityElements(
189+
in: view,
190+
userInterfaceLayoutDirectionProvider: TestUserInterfaceLayoutDirectionProvider(userInterfaceLayoutDirection: .leftToRight),
191+
userInterfaceIdiomProvider: TestUserInterfaceIdiomProvider(userInterfaceIdiom: .phone)
192+
)
193+
}
194+
}
195+
196+
// MARK: -
197+
198+
private final class ActivationPointTestView: UIView {
199+
var overriddenFrame: CGRect?
200+
var overriddenActivationPoint: CGPoint?
201+
var overriddenPath: UIBezierPath?
202+
203+
override var accessibilityFrame: CGRect {
204+
get { overriddenFrame ?? super.accessibilityFrame }
205+
set { overriddenFrame = newValue }
206+
}
207+
208+
override var accessibilityActivationPoint: CGPoint {
209+
get { overriddenActivationPoint ?? super.accessibilityActivationPoint }
210+
set { overriddenActivationPoint = newValue }
211+
}
212+
213+
override var accessibilityPath: UIBezierPath? {
214+
get { overriddenPath ?? super.accessibilityPath }
215+
set { overriddenPath = newValue }
216+
}
117217
}
118218

119219
// MARK: -

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

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -300,9 +300,10 @@ public final class AccessibilityHierarchyParser {
300300
userInputLabels: element.object.accessibilityUserInputLabels,
301301
shape: Self.accessibilityShape(for: element.object, in: root),
302302
activationPoint: root.convert(activationPoint, from: nil),
303-
usesDefaultActivationPoint: activationPoint.approximatelyEquals(
304-
Self.defaultActivationPoint(for: element.object),
305-
tolerance: 1 / (root.window?.screen ?? UIScreen.main).scale
303+
usesDefaultActivationPoint: Self.usesDefaultActivationPoint(
304+
element: element.object,
305+
activationPoint: activationPoint,
306+
screenScale: (root.window?.screen ?? UIScreen.main).scale
306307
),
307308
customActions: element.object.accessibilityCustomActions?.map { AccessibilityMarker.CustomAction(name: $0.name, image: $0.image) } ?? [],
308309
customContent: element.object.customContent,
@@ -633,6 +634,43 @@ private extension AccessibilityHierarchyParser {
633634
}
634635
}
635636

637+
/// Determines whether an element is using its default activation point.
638+
///
639+
/// When both the activation point and frame are zero, the element hasn't set a custom activation
640+
/// point — it's just reporting the default for a zero frame. This can happen with SwiftUI elements
641+
/// whose `accessibilityFrame` is `.zero`.
642+
static func usesDefaultActivationPoint(
643+
element: NSObject,
644+
activationPoint: CGPoint,
645+
screenScale: CGFloat
646+
) -> Bool {
647+
if activationPoint == .zero && element.accessibilityFrame == .zero {
648+
return true
649+
}
650+
651+
return activationPoint.approximatelyEquals(
652+
defaultActivationPoint(for: element),
653+
tolerance: 1 / screenScale
654+
)
655+
}
656+
657+
/// Returns the effective screen-coordinate frame for an accessibility element.
658+
///
659+
/// Some SwiftUI elements provide an `accessibilityPath` but report a zero `accessibilityFrame`.
660+
/// In those cases, the path bounds (which are already in screen coordinates) are used instead.
661+
static func effectiveAccessibilityFrame(for element: NSObject) -> CGRect {
662+
let frame = element.accessibilityFrame
663+
if !frame.isEmpty {
664+
return frame
665+
}
666+
667+
if let path = element.accessibilityPath {
668+
return path.bounds
669+
}
670+
671+
return frame
672+
}
673+
636674
/// Returns the default value for an element's `accessibilityActivationPoint`.
637675
static func defaultActivationPoint(for element: NSObject) -> CGPoint {
638676
if let element = element as? UISlider {
@@ -646,8 +684,8 @@ private extension AccessibilityHierarchyParser {
646684

647685
// By default, an element's activation point is the center of its accessibility frame, regardless of whether it
648686
// uses an accessibility path or frame as its shape.
649-
let accessibilityFrame = element.accessibilityFrame
650-
return CGPoint(x: accessibilityFrame.midX, y: accessibilityFrame.midY)
687+
let frame = effectiveAccessibilityFrame(for: element)
688+
return CGPoint(x: frame.midX, y: frame.midY)
651689
}
652690
}
653691

0 commit comments

Comments
 (0)