Skip to content

Commit 86a3ca9

Browse files
Adjusting sort order to more closely match VoiceOver (#242)
This change implements out two subtle details in VoiceOver's positional element sorting. - VoiceOver element sorting always uses accessibility frames, even when a path is present. - VoiceOver treats accessibility frames with an origin.y delta of less than 8 to be in line with one another and eligible for sorting by leading -> trailing.
1 parent 5bc3728 commit 86a3ca9

File tree

7 files changed

+257
-14
lines changed

7 files changed

+257
-14
lines changed

Example/AccessibilitySnapshot.xcodeproj/project.pbxproj

+11-2
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
66E2CD14CD63946657E17B15 /* Pods_SnapshotTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A3192D7B9B16BD10FB517A2 /* Pods_SnapshotTests.framework */; };
5858
83A295842AC22D9D00DFBE4F /* UserInputLabelsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A295832AC22D9D00DFBE4F /* UserInputLabelsViewController.swift */; };
5959
83A295862AC22EEE00DFBE4F /* UserInputLabelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A295852AC22EEE00DFBE4F /* UserInputLabelsTests.swift */; };
60+
AC59D4702D83326F0096B803 /* ElementFrameComparisonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC59D46F2D83326F0096B803 /* ElementFrameComparisonController.swift */; };
61+
AC59D4722D8333090096B803 /* ElementFrameComparisonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC59D4712D8333020096B803 /* ElementFrameComparisonTests.swift */; };
6062
AC5C5FE22C627DA300E1C4E7 /* NavBarBackButtonAccessibilityTraitsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5C5FE12C627DA300E1C4E7 /* NavBarBackButtonAccessibilityTraitsViewController.swift */; };
6163
AC725B842B06D07E009AD59B /* AccessibilityCustomContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC725B832B06D07E009AD59B /* AccessibilityCustomContentViewController.swift */; };
6264
C47F6C5316FB0C043BEB59F3 /* Pods_AccessibilitySnapshotDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6A886964D2E787399E137105 /* Pods_AccessibilitySnapshotDemo.framework */; };
@@ -150,6 +152,8 @@
150152
83A295832AC22D9D00DFBE4F /* UserInputLabelsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserInputLabelsViewController.swift; sourceTree = "<group>"; };
151153
83A295852AC22EEE00DFBE4F /* UserInputLabelsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserInputLabelsTests.swift; sourceTree = "<group>"; };
152154
88C33CBF672C290CE1EE86AF /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = "<group>"; };
155+
AC59D46F2D83326F0096B803 /* ElementFrameComparisonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementFrameComparisonController.swift; sourceTree = "<group>"; };
156+
AC59D4712D8333020096B803 /* ElementFrameComparisonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementFrameComparisonTests.swift; sourceTree = "<group>"; };
153157
AC5C5FE12C627DA300E1C4E7 /* NavBarBackButtonAccessibilityTraitsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavBarBackButtonAccessibilityTraitsViewController.swift; sourceTree = "<group>"; };
154158
AC725B832B06D07E009AD59B /* AccessibilityCustomContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityCustomContentViewController.swift; sourceTree = "<group>"; };
155159
C78F90CE7A2A315AADF80144 /* Pods-SnapshotTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SnapshotTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SnapshotTests/Pods-SnapshotTests.debug.xcconfig"; sourceTree = "<group>"; };
@@ -203,6 +207,7 @@
203207
3D9894F8213509C8006C16F6 /* DescriptionEdgeCasesViewController.swift */,
204208
3DF464FD220D594E0048D446 /* ElementSelectionViewController.swift */,
205209
3DF46501220D7F9B0048D446 /* ElementOrderViewController.swift */,
210+
AC59D46F2D83326F0096B803 /* ElementFrameComparisonController.swift */,
206211
3D0AC55B222DD70A00B6F1C1 /* UserInterfaceDirectionViewController.swift */,
207212
3DEBF24D221018610065424F /* DefaultControlsViewController.swift */,
208213
3DBEAA5A2222953E00FAE61D /* SwitchControlViewController.swift */,
@@ -325,6 +330,7 @@
325330
3D39BFAF2239BC42009C3EF4 /* ActivationPointTests.swift */,
326331
3DEBF24F22101EE40065424F /* DefaultControlsTests.swift */,
327332
3DF46503220D8C500048D446 /* ElementOrderTests.swift */,
333+
AC59D4712D8333020096B803 /* ElementFrameComparisonTests.swift */,
328334
3DF464FF220D5FB00048D446 /* ElementSelectionTests.swift */,
329335
3DC2C67A21F45FFF003184E4 /* HighlightTests.swift */,
330336
3D3F2E152263E94D00F7608E /* InvertColorsTests.swift */,
@@ -448,6 +454,7 @@
448454
};
449455
607FACCF1AFB9204008FA782 = {
450456
CreatedOnToolsVersion = 6.3.1;
457+
DevelopmentTeam = Y4XC6NM5DD;
451458
LastSwiftMigration = 0900;
452459
};
453460
607FACE41AFB9204008FA782 = {
@@ -642,6 +649,7 @@
642649
3DA12A3222405B9E00EB3C33 /* DataTableViewController.swift in Sources */,
643650
3DEBF24E221018610065424F /* DefaultControlsViewController.swift in Sources */,
644651
1104A8D12B595FF600B6715F /* TextFieldViewController.swift in Sources */,
652+
AC59D4702D83326F0096B803 /* ElementFrameComparisonController.swift in Sources */,
645653
3FEF854F253846420072611F /* SwiftUIViewWithScrollView.swift in Sources */,
646654
3DF464FE220D594E0048D446 /* ElementSelectionViewController.swift in Sources */,
647655
3DC488372212A7D4006D1E15 /* ModalAccessibilityViewController.swift in Sources */,
@@ -681,6 +689,7 @@
681689
D2F76EED2945C879000A453F /* HitTargetTests.swift in Sources */,
682690
3DF46504220D8C500048D446 /* ElementOrderTests.swift in Sources */,
683691
3DEBF25022101EE40065424F /* DefaultControlsTests.swift in Sources */,
692+
AC59D4722D8333090096B803 /* ElementFrameComparisonTests.swift in Sources */,
684693
);
685694
runOnlyForDeploymentPostprocessing = 0;
686695
};
@@ -879,7 +888,7 @@
879888
baseConfigurationReference = ED63B7AD78B189E8940B6C80 /* Pods-AccessibilitySnapshotDemo.debug.xcconfig */;
880889
buildSettings = {
881890
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
882-
DEVELOPMENT_TEAM = "";
891+
DEVELOPMENT_TEAM = Y4XC6NM5DD;
883892
INFOPLIST_FILE = AccessibilitySnapshot/Info.plist;
884893
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
885894
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@@ -897,7 +906,7 @@
897906
baseConfigurationReference = CCFF2A604706B71DC0CBD38B /* Pods-AccessibilitySnapshotDemo.release.xcconfig */;
898907
buildSettings = {
899908
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
900-
DEVELOPMENT_TEAM = "";
909+
DEVELOPMENT_TEAM = Y4XC6NM5DD;
901910
INFOPLIST_FILE = AccessibilitySnapshot/Info.plist;
902911
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
903912
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
//
2+
// Copyright 2025 Square Inc.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//
16+
17+
18+
19+
import UIKit
20+
21+
class ElementFrameComparisonController: AccessibilityViewController {
22+
23+
override func viewDidLoad() {
24+
super.viewDidLoad()
25+
26+
view.backgroundColor = .white
27+
28+
let frameHeader = MyLabel("Adjusting the accessibility frame vertically by > 8.0 alters the sort order")
29+
frameHeader.accessibilityTraits = [.staticText, .header]
30+
31+
let pathHeader = MyLabel("Accessibility path is ignored entirely for sorting purposes")
32+
pathHeader.accessibilityTraits = [.staticText, .header]
33+
34+
let spacing = 20.0
35+
36+
// 8 seems to be the magic number for VoiceOver to consider it
37+
// to be vertically "above" other views
38+
let voiceoverMagicNumber = 8.0
39+
40+
let frames = UIStackView(arrangedSubviews: [
41+
// Label where the accessibilityFrame will be the view's frame
42+
// translated vertically by 8 pt down
43+
MyLabel(
44+
"frame down",
45+
accessibilityLabel: "Third",
46+
accessibilityFrame: .rect({
47+
var rect = $0
48+
rect.origin.y += voiceoverMagicNumber
49+
return rect
50+
})
51+
),
52+
53+
// Label where the accessibilityFrame is unmodified
54+
MyLabel( "unchanged", accessibilityLabel: "Second"),
55+
56+
// Label where the accessibilityFrame will be the view's frame
57+
// translated vertically by 8 pt up
58+
MyLabel(
59+
"frame up",
60+
accessibilityLabel: "First",
61+
accessibilityFrame: .rect({
62+
var rect = $0
63+
rect.origin.y -= voiceoverMagicNumber
64+
return rect
65+
})
66+
)
67+
])
68+
69+
let paths = UIStackView(arrangedSubviews: [
70+
71+
// Label where the accessibilityPath will be the view's frame
72+
// translated vertically by 8 pt down
73+
MyLabel(
74+
"path down",
75+
accessibilityLabel: "Fourth",
76+
accessibilityFrame: .path({
77+
var rect = $0
78+
rect.origin.y += voiceoverMagicNumber
79+
return UIBezierPath(roundedRect: rect, cornerRadius: 5)
80+
})
81+
),
82+
83+
// Label where the accessibilityPath is unmodified
84+
MyLabel(
85+
"path unchanged",
86+
accessibilityLabel: "Fifth",
87+
accessibilityFrame: .path({
88+
return UIBezierPath(roundedRect: $0, cornerRadius: 5)
89+
})
90+
),
91+
92+
93+
// Label where the accessibilityPath will be the view's frame
94+
// translated vertically by 8 pt up
95+
MyLabel(
96+
"path up",
97+
accessibilityLabel: "Sixth",
98+
accessibilityFrame: .path({
99+
var rect = $0
100+
rect.origin.y -= voiceoverMagicNumber
101+
return UIBezierPath(roundedRect: rect, cornerRadius: 5)
102+
})
103+
)
104+
105+
])
106+
107+
frames.axis = .horizontal
108+
paths.axis = .horizontal
109+
110+
frames.spacing = spacing
111+
paths.spacing = spacing
112+
113+
view.addSubview(frameHeader)
114+
view.addSubview(frames)
115+
116+
view.addSubview(pathHeader)
117+
view.addSubview(paths)
118+
119+
frameHeader.translatesAutoresizingMaskIntoConstraints = false
120+
frames.translatesAutoresizingMaskIntoConstraints = false
121+
122+
pathHeader.translatesAutoresizingMaskIntoConstraints = false
123+
paths.translatesAutoresizingMaskIntoConstraints = false
124+
125+
NSLayoutConstraint.activate([
126+
frameHeader.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: spacing),
127+
frameHeader.leadingAnchor.constraint(equalTo: view.leadingAnchor),
128+
frameHeader.trailingAnchor.constraint(equalTo: view.trailingAnchor),
129+
130+
frames.topAnchor.constraint(equalTo: frameHeader.bottomAnchor, constant: spacing),
131+
frames.leadingAnchor.constraint(equalTo: view.leadingAnchor),
132+
frames.trailingAnchor.constraint(equalTo: view.trailingAnchor),
133+
134+
pathHeader.topAnchor.constraint(equalTo: frames.bottomAnchor, constant: spacing),
135+
pathHeader.leadingAnchor.constraint(equalTo: view.leadingAnchor),
136+
pathHeader.trailingAnchor.constraint(equalTo: view.trailingAnchor),
137+
138+
paths.topAnchor.constraint(equalTo: pathHeader.bottomAnchor, constant: spacing),
139+
paths.leadingAnchor.constraint(equalTo: view.leadingAnchor),
140+
paths.trailingAnchor.constraint(equalTo: view.trailingAnchor),
141+
142+
])
143+
}
144+
}
145+
146+
enum AccessibilityFrame {
147+
case `default`
148+
case path((CGRect) -> UIBezierPath)
149+
case rect((CGRect) -> CGRect)
150+
}
151+
152+
class MyLabel: UILabel {
153+
154+
private let _accessibilityFrame: AccessibilityFrame
155+
156+
init(_ text: String, align: NSTextAlignment = .left, accessibilityLabel: String? = nil, accessibilityFrame: AccessibilityFrame = .default) {
157+
self._accessibilityFrame = accessibilityFrame
158+
super.init(frame: .zero)
159+
self.text = text
160+
self.textAlignment = align
161+
self.backgroundColor = .gray.withAlphaComponent(0.2)
162+
if let accessibilityLabel {
163+
self.accessibilityLabel = accessibilityLabel
164+
}
165+
166+
self.numberOfLines = 0
167+
self.lineBreakMode = .byWordWrapping
168+
}
169+
170+
required init?(coder: NSCoder) { nil }
171+
172+
override var accessibilityPath: UIBezierPath? {
173+
set { _ = newValue }
174+
get {
175+
switch _accessibilityFrame {
176+
177+
case .default, .rect:
178+
return nil
179+
180+
case .path(let transform):
181+
guard let superview else { return nil }
182+
return UIAccessibility.convertToScreenCoordinates(transform(frame), in: superview)
183+
}
184+
}
185+
}
186+
187+
override var accessibilityFrame: CGRect {
188+
set { _ = newValue }
189+
get {
190+
switch _accessibilityFrame {
191+
case .default, .path:
192+
return super.accessibilityFrame
193+
194+
case .rect(let transform):
195+
guard let superview else { return super.accessibilityFrame }
196+
return UIAccessibility.convertToScreenCoordinates(transform(frame), in: superview)
197+
}
198+
}
199+
}
200+
}

Example/AccessibilitySnapshot/RootViewController.swift

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ final class RootViewController: UITableViewController {
4545
presentingViewController: presentingViewController
4646
)
4747
}),
48+
("Element Frame Comparison", { _ in return ElementFrameComparisonController() }),
4849
("Element Order with Semantic Content", { _ in return UserIntefaceDirectionViewController() }),
4950
("Modal Accessibility Views", { presentingViewController in
5051
return ModalAccessibilityViewController.makeConfigurationSelectionViewController(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//
2+
// Copyright 2019 Square Inc.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//
16+
17+
import AccessibilitySnapshot
18+
import FBSnapshotTestCase
19+
20+
@testable import AccessibilitySnapshotDemo
21+
22+
final class ElementFrameComparisonTests: SnapshotTestCase {
23+
24+
func testFrames() {
25+
let viewController = ElementFrameComparisonController()
26+
viewController.view.frame = UIScreen.main.bounds
27+
SnapshotVerifyAccessibility(viewController.view)
28+
}
29+
30+
}

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

+15-12
Original file line numberDiff line numberDiff line change
@@ -299,14 +299,18 @@ public final class AccessibilityHierarchyParser {
299299
@unknown default:
300300
fatalError("Unknown user interface layout direction: \(userInterfaceLayoutDirection)")
301301
}
302+
303+
// 8 seems to be the magic number for VoiceOver to consider it
304+
// to be vertically "above" other views.
305+
let minimumVerticalSeparation = 8.0
302306

303307
let sortedNodes = explicitlyOrdered ? nodes : nodes
304-
.map { ($0, accessibilityBoundingBox(for: $0, in: root)) }
308+
.map { ($0, accessibilitySortFrame(for: $0, in: root)) }
305309
.sorted { obj1, obj2 in
306310
let origin1 = obj1.1.origin
307311
let origin2 = obj2.1.origin
308312

309-
if origin1.y != origin2.y {
313+
if origin1.y != origin2.y, abs(origin1.y - origin2.y) >= minimumVerticalSeparation {
310314
return origin1.y < origin2.y
311315
}
312316

@@ -335,28 +339,27 @@ public final class AccessibilityHierarchyParser {
335339

336340
return sortedElements
337341
}
338-
339-
/// Returns the bounding box of the accessibility node in the root view's coordinate space.
340-
private func accessibilityBoundingBox(for node: AccessibilityNode, in root: UIView) -> CGRect {
342+
/// Returns a CGRect that can be used for sorting by position.
343+
private func accessibilitySortFrame(for node: AccessibilityNode, in root: UIView) -> CGRect {
341344
switch node {
342345
case let .element(frameProvider, _),
343346
let .group(_, _, frameProvider?):
344-
switch accessibilityShape(for: frameProvider, in: root) {
347+
switch accessibilityShape(for: frameProvider, in: root, preferPath: false) {
345348
case let .frame(rect):
346349
return rect
347-
348-
case let .path(path):
349-
return path.bounds
350+
default:
351+
return frameProvider.accessibilityFrame
350352
}
351353

352354
case let .group(elements, _, _):
353-
return elements.reduce(CGRect.null) { $0.union(accessibilityBoundingBox(for: $1, in: root)) }
355+
return elements.reduce(CGRect.null) { $0.union(accessibilitySortFrame(for: $1, in: root)) }
354356
}
355357
}
356358

357359
/// Returns the shape of the accessibility element in the root view's coordinate space.
358-
private func accessibilityShape(for element: NSObject, in root: UIView) -> AccessibilityMarker.Shape {
359-
if let accessibilityPath = element.accessibilityPath {
360+
/// Voiceover prefers an accessibilityPath if available when drawing the bounding box, but the accessibilityFrame is always used for sort order.
361+
private func accessibilityShape(for element: NSObject, in root: UIView, preferPath: Bool = true) -> AccessibilityMarker.Shape {
362+
if let accessibilityPath = element.accessibilityPath, preferPath {
360363
return .path(root.convert(accessibilityPath, from: nil))
361364

362365
} else if let element = element as? UIAccessibilityElement, let container = element.accessibilityContainer, !element.accessibilityFrameInContainerSpace.isNull {

0 commit comments

Comments
 (0)