Skip to content

Commit 5d53aa4

Browse files
committed
.
1 parent 7e5f33b commit 5d53aa4

File tree

8 files changed

+223
-46
lines changed

8 files changed

+223
-46
lines changed

BlueprintUI/Sources/Element/Accessibility.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,49 @@ extension AXCustomContent {
205205
}
206206
}
207207

208+
extension Accessibility {
209+
public static func frameSort(
210+
direction: Environment.LayoutDirection,
211+
root: UIView,
212+
userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom
213+
) -> (NSObject, NSObject) -> Bool {
214+
{
215+
let first = root.convert($0.accessibilityFrame, from: nil)
216+
let second = root.convert($1.accessibilityFrame, from: nil)
217+
218+
// Horizontal sorting logic - reusable for both center-aligned and fallback cases
219+
let sortHorizontally = {
220+
switch direction {
221+
case .leftToRight:
222+
return first.minX < second.minX
223+
case .rightToLeft:
224+
return first.maxX > second.maxX
225+
}
226+
}
227+
228+
// Check if elements are vertically aligned along their central axis first.
229+
// While this check deviates from VoiceOver's behavior for UIKit, it covers one frequent
230+
// use case of Blueprint Row where it contains a number of elements with their
231+
// verticalAlignment set to .center. Since there's no view representation for Row,
232+
// checking for midY alignment is a reasonable heuristic in its absence.
233+
let centerYTolerance: CGFloat = 1.0
234+
let centerYDelta = abs(first.midY - second.midY)
235+
236+
if centerYDelta <= centerYTolerance {
237+
// Elements are center-aligned, sort horizontally.
238+
return sortHorizontally()
239+
}
240+
241+
// Derived through experimentation, this mimics the default sorting for UIKit.
242+
// If frames differ by more than 8 points the top most element is preferred.
243+
let minYTolerance = userInterfaceIdiom == .phone ? 8.0 : 13.0
244+
let minYDelta = abs(first.minY - second.minY)
245+
if minYDelta <= minYTolerance {
246+
// Elements are within vertical tolerance, sort horizontally.
247+
return sortHorizontally()
248+
}
249+
250+
return first.minY < second.minY
251+
}
252+
}
253+
}

BlueprintUICommonControls/Sources/AccessibilityContainer.swift

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public struct AccessibilityContainer: Element {
6262
config[\.accessibilityValue] = value
6363
config[\.accessibilityIdentifier] = identifier
6464
config[\.accessibilityContainerType] = containerType.UIKitContainerType
65+
config[\.layoutDirection] = context.environment.layoutDirection
6566
}
6667
}
6768
}
@@ -106,25 +107,78 @@ extension Element {
106107

107108
extension AccessibilityContainer {
108109
private final class AccessibilityContainerView: UIView {
110+
var layoutDirection: Environment.LayoutDirection = .leftToRight
111+
109112
override var accessibilityElements: [Any]? {
110-
get { recursiveAccessibleSubviews() }
113+
get {
114+
accessibilityElements(
115+
layoutDirection: layoutDirection
116+
)
117+
}
111118
set { fatalError("This property is not settable") }
112119
}
113120
}
114121
}
115122

116123
extension UIView {
117-
func recursiveAccessibleSubviews() -> [Any] {
118-
subviews.flatMap { subview -> [Any] in
124+
func accessibilityElements(
125+
layoutDirection: Environment.LayoutDirection
126+
) -> [NSObject] {
127+
recursiveAccessibilityElements().sorted(by: Accessibility.frameSort(
128+
direction: layoutDirection,
129+
root: self
130+
))
131+
}
132+
133+
@objc override func recursiveAccessibilityElements() -> [NSObject] {
134+
subviews.flatMap { subview -> [NSObject] in
119135
if subview.accessibilityElementsHidden || subview.isHidden {
120136
return []
121-
} else if let accessibilityElements = subview.accessibilityElements {
122-
return accessibilityElements
123-
} else if subview.isAccessibilityElement {
137+
}
138+
139+
// UICollectionView is a special case because it uses virtualization to only show a subset of its elements.
140+
// By doing this, we outsource the logic of specifying the accessibility elements to the collection view itself.
141+
// If we did not do this, we would only make the visible cells accessible, and it would prevent the user from
142+
// scrolling/swiping to cells outside the visible area.
143+
if let collectionView = subview as? UICollectionView {
144+
return [collectionView]
145+
}
146+
147+
// UITableView is a similar special case as UICollectionView
148+
if let tableView = subview as? UITableView {
149+
return [tableView]
150+
}
151+
152+
if let accessibilityElements = subview.accessibilityElements {
153+
return accessibilityElements.compactMap { element -> [NSObject] in
154+
guard let elementObj = element as? NSObject else { return [] }
155+
156+
if elementObj.isAccessibilityElement {
157+
return [elementObj]
158+
}
159+
160+
return elementObj.recursiveAccessibilityElements()
161+
}.flatMap { $0 }
162+
}
163+
164+
if subview.isAccessibilityElement {
124165
return [subview]
125-
} else {
126-
return subview.recursiveAccessibleSubviews()
127166
}
167+
168+
return subview.recursiveAccessibilityElements()
128169
}
129170
}
130171
}
172+
173+
extension NSObject {
174+
@objc func recursiveAccessibilityElements() -> [NSObject] {
175+
accessibilityElements?.flatMap { element -> [NSObject] in
176+
guard let element = element as? NSObject else { return [] }
177+
if element.isAccessibilityElement {
178+
return [element]
179+
}
180+
181+
return element.recursiveAccessibilityElements()
182+
} ?? []
183+
}
184+
}

BlueprintUICommonControls/Sources/ScrollView.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -466,8 +466,14 @@ extension ScrollView {
466466
var finalContentInset = scrollViewInsets
467467

468468
// Include the keyboard's adjustment at the bottom of the scroll view.
469-
470-
if keyboardBottomInset > 0.0 {
469+
//
470+
// We check for values over 1.0 because of how BlueprintView rounds its root node's
471+
// frame. This rounding can increase a view's frame so that it extends slightly
472+
// offscreen. For example, a view height of 715.51 on a 3x device is rounded to a
473+
// height of 715.66. If this view is anchored to the bottom of the screen, it will
474+
// technically overlap the dismissed keyboard by 0.15pts. We filter out these cases.
475+
476+
if keyboardBottomInset > 1.0 {
471477
finalContentInset.bottom += keyboardBottomInset
472478

473479
// Exclude the safe area insets, so the content hugs the top of the keyboard.

BlueprintUICommonControls/Tests/Sources/AccessibilityContainerTests.swift

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ class AccessibilityContainerTests: XCTestCase {
1010
let viewA = UIView()
1111
let viewB = UIView()
1212

13+
viewA.isAccessibilityElement = true
14+
viewB.isAccessibilityElement = true
15+
1316
let innerContainerView = UIView()
1417
innerContainerView.accessibilityElements = [viewA, viewB]
1518

1619
let outerContainerView = UIView()
1720
outerContainerView.addSubview(innerContainerView)
1821

19-
let accessibleSubviews = outerContainerView.recursiveAccessibleSubviews() as! [UIView]
22+
let accessibleSubviews = outerContainerView.accessibilityElements(layoutDirection: .leftToRight) as! [UIView]
2023

2124
XCTAssertEqual(accessibleSubviews.count, 2)
2225
XCTAssertTrue(accessibleSubviews.contains(where: { $0 === viewA }))
@@ -34,58 +37,88 @@ class AccessibilityContainerTests: XCTestCase {
3437
let containerView = UIView()
3538
containerView.addSubview(wrapperView)
3639

37-
let accessibleSubviews = containerView.recursiveAccessibleSubviews() as! [UIView]
40+
let accessibleSubviews = containerView.accessibilityElements(layoutDirection: .leftToRight) as! [UIView]
3841

3942
XCTAssertEqual(accessibleSubviews[0], accessibleView)
4043
}
4144

42-
func test_accessibilityElementsAndContainedViewsAreFound() {
45+
func test_searchIsTerminatedAtAccessibleViewButContinuesIntoNonAccessibleContainerElements() {
46+
let deeplyNestedAccessible = UIView()
47+
deeplyNestedAccessible.isAccessibilityElement = true
4348

44-
let viewA = UIView()
49+
let nonAccessibleContainerElement = UIView()
50+
nonAccessibleContainerElement.addSubview(deeplyNestedAccessible)
51+
52+
let undiscoveredViewB = UIView()
53+
undiscoveredViewB.isAccessibilityElement = true
4554

4655
let innerContainerView = UIView()
47-
innerContainerView.accessibilityElements = [viewA]
56+
// This container has a non-accessible UIView element that should be recursively processed
57+
innerContainerView.accessibilityElements = [nonAccessibleContainerElement]
4858

4959
let accessibleView = UIView()
5060
accessibleView.isAccessibilityElement = true
61+
// This accessible view's children should not be discovered since search stops at accessibility elements
62+
accessibleView.addSubview(undiscoveredViewB)
5163

5264
let outerContainerView = UIView()
5365
outerContainerView.addSubview(accessibleView)
5466
outerContainerView.addSubview(innerContainerView)
5567

56-
let accessibleSubviews = outerContainerView.recursiveAccessibleSubviews() as! [UIView]
68+
let accessibleSubviews = outerContainerView.accessibilityElements(layoutDirection: .leftToRight) as! [UIView]
5769

5870
XCTAssertEqual(accessibleSubviews.count, 2)
59-
XCTAssertTrue(accessibleSubviews.contains(where: { $0 === viewA }))
71+
// Should find the deeply nested accessible view through recursive processing
72+
XCTAssertTrue(accessibleSubviews.contains(where: { $0 === deeplyNestedAccessible }))
6073
XCTAssertTrue(accessibleSubviews.contains(where: { $0 === accessibleView }))
74+
// Should NOT find this because search stops at accessible views
75+
XCTAssertFalse(accessibleSubviews.contains(where: { $0 === undiscoveredViewB }))
6176
}
6277

63-
func test_searchIsTerminatedAtContainerOrAccessibleView() {
64-
let undiscoveredViewA = UIView()
65-
undiscoveredViewA.isAccessibilityElement = true
78+
func test_recursiveProcessingOfAccessibilityElementsUIViews() {
79+
// Create a hierarchy where accessibilityElements contains UIViews that need further processing
80+
let finalAccessibleView = UIView()
81+
finalAccessibleView.isAccessibilityElement = true
6682

67-
let undiscoveredViewB = UIView()
68-
undiscoveredViewB.isAccessibilityElement = true
83+
let intermediateContainer = UIView()
84+
intermediateContainer.addSubview(finalAccessibleView)
6985

70-
let viewA = UIView()
86+
let parentContainer = UIView()
87+
// The accessibility elements list contains a UIView that is not an accessibility element itself
88+
// but contains accessible elements within it
89+
parentContainer.accessibilityElements = [intermediateContainer]
7190

72-
let innerContainerView = UIView()
73-
innerContainerView.accessibilityElements = [viewA]
74-
innerContainerView.addSubview(undiscoveredViewA)
91+
let rootContainer = UIView()
92+
rootContainer.addSubview(parentContainer)
7593

76-
let accessibleView = UIView()
77-
accessibleView.isAccessibilityElement = true
78-
accessibleView.addSubview(undiscoveredViewB)
94+
let accessibleSubviews = rootContainer.accessibilityElements(layoutDirection: .leftToRight) as! [UIView]
7995

80-
let outerContainerView = UIView()
81-
outerContainerView.addSubview(accessibleView)
82-
outerContainerView.addSubview(innerContainerView)
96+
XCTAssertEqual(accessibleSubviews.count, 1)
97+
XCTAssertTrue(accessibleSubviews.contains(where: { $0 === finalAccessibleView }))
98+
}
8399

84-
let accessibleSubviews = outerContainerView.recursiveAccessibleSubviews() as! [UIView]
100+
func test_mixedAccessibilityElementsWithAccessibleAndNonAccessibleUIViews() {
101+
let directAccessibleView = UIView()
102+
directAccessibleView.isAccessibilityElement = true
103+
104+
let deeplyNestedAccessible = UIView()
105+
deeplyNestedAccessible.isAccessibilityElement = true
106+
107+
let nonAccessibleContainer = UIView()
108+
nonAccessibleContainer.addSubview(deeplyNestedAccessible)
109+
110+
let parentContainer = UIView()
111+
// Mix of directly accessible UIView and UIView that needs recursive processing
112+
parentContainer.accessibilityElements = [directAccessibleView, nonAccessibleContainer]
113+
114+
let rootContainer = UIView()
115+
rootContainer.addSubview(parentContainer)
116+
117+
let accessibleSubviews = rootContainer.accessibilityElements(layoutDirection: .leftToRight) as! [UIView]
85118

86119
XCTAssertEqual(accessibleSubviews.count, 2)
87-
XCTAssertFalse(accessibleSubviews.contains(where: { $0 === undiscoveredViewA }))
88-
XCTAssertFalse(accessibleSubviews.contains(where: { $0 === undiscoveredViewB }))
120+
XCTAssertTrue(accessibleSubviews.contains(where: { $0 === directAccessibleView }))
121+
XCTAssertTrue(accessibleSubviews.contains(where: { $0 === deeplyNestedAccessible }))
89122
}
90123

91124
func test_accessibilityElementHiddenNotAccessible() {
@@ -100,7 +133,7 @@ class AccessibilityContainerTests: XCTestCase {
100133
let containerView = UIView()
101134
containerView.addSubview(wrapperView)
102135

103-
let accessibleSubviews = containerView.recursiveAccessibleSubviews() as! [UIView]
136+
let accessibleSubviews = containerView.accessibilityElements(layoutDirection: .leftToRight) as! [UIView]
104137

105138
XCTAssertNil(accessibleSubviews.first)
106139
}
@@ -117,7 +150,7 @@ class AccessibilityContainerTests: XCTestCase {
117150
let containerView = UIView()
118151
containerView.addSubview(wrapperView)
119152

120-
let accessibleSubviews = containerView.recursiveAccessibleSubviews() as! [UIView]
153+
let accessibleSubviews = containerView.accessibilityElements(layoutDirection: .leftToRight) as! [UIView]
121154

122155
XCTAssertNil(accessibleSubviews.first)
123156
}

BlueprintUICommonControls/Tests/Sources/ScrollViewTests.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,21 @@ class ScrollViewTests: XCTestCase {
7878
)
7979
)
8080

81+
// Anomalous Keyboard Inset
82+
83+
XCTAssertEqual(
84+
UIEdgeInsets(top: 10.0, left: 11.0, bottom: 12.0, right: 13.0),
85+
86+
ScrollView.calculateContentInset(
87+
scrollViewInsets: UIEdgeInsets(top: 10.0, left: 11.0, bottom: 12.0, right: 13.0),
88+
safeAreaInsets: UIEdgeInsets(top: 10.0, left: 11.0, bottom: 12.0, right: 13.0),
89+
// Since this value is < 1, the resulting bottom inset should be unchanged.
90+
keyboardBottomInset: 0.15,
91+
refreshControlState: .disabled,
92+
refreshControlBounds: .zero
93+
)
94+
)
95+
8196
// Keyboard Inset and refreshing state
8297
let expectedTopInset = 10.0
8398

CHANGELOG.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12-
- Fixed bounding rects for VoiceOver when an attributed label's link spans more than one line.
13-
- Fixed an issue where resizing a `ScrollView` could result in its scroll position being adjusted incorrectly.
14-
1512
### Added
1613

17-
- Added support for tabbing through links in `AttributedLabel`
18-
1914
### Removed
2015

2116
### Changed
@@ -30,6 +25,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3025

3126
### Internal
3227

28+
## [6.1.0] - 2025-07-22
29+
30+
### Fixed
31+
- Fixed bounding rects for VoiceOver when an attributed label's link spans more than one line.
32+
- Fixed an issue where resizing a `ScrollView` could result in its scroll position being adjusted incorrectly.
33+
- Fixed an issue where a dismissed keyboard could impact a `ScrollView`'s bottom safe area inset.
34+
- Fixed `AccessibilityContainer` to better handle the accessibility ordering for a `UITableView` or a `UICollectionView` inside it (such as a `Listable` instance).
35+
36+
### Added
37+
38+
- Added support for tabbing through links in `AttributedLabel`
39+
3340
## [6.0.0] - 2025-06-16
3441

3542
### Added
@@ -1253,7 +1260,8 @@ searchField
12531260

12541261
- First stable release.
12551262

1256-
[main]: https://github.com/square/Blueprint/compare/6.0.0...HEAD
1263+
[main]: https://github.com/square/Blueprint/compare/6.1.0...HEAD
1264+
[6.1.0]: https://github.com/square/Blueprint/compare/6.0.0...6.1.0
12571265
[6.0.0]: https://github.com/square/Blueprint/compare/5.7.0...6.0.0
12581266
[5.7.0]: https://github.com/square/Blueprint/compare/5.6.0...5.7.0
12591267
[5.6.0]: https://github.com/square/Blueprint/compare/5.5.0...5.6.0

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// swift-tools-version:5.9
1+
// swift-tools-version:6.1
22
// The swift-tools-version declares the minimum version of Swift required to build this package.
33

44
import PackageDescription

0 commit comments

Comments
 (0)