Skip to content

Commit f91cf8f

Browse files
Adding safe area edge detection for the scrollableAxes behavior (#596)
Adding a `ScrollView` API to allow clients to specify the edges that should be inset for the safe area when using the `scrollableAxes` content inset behavior. Before this change, content that landed outside the safe area but inside the scroll view bounds was not scrollable when using [`scrollableAxes`](https://developer.apple.com/documentation/uikit/uiscrollview/contentinsetadjustmentbehavior-swift.enum/scrollableaxes). (See linked docs for details) The goal of this update is to address the issue of content not being scrollable without introducing breaking API changes or large behavioral changes to ScrollView. A few alternative approaches could be: - turn on `alwaysBouncesVertical` and/or `alwaysBounceHorizontal`, which will cause `scrollableAxes` to honor the safe areas along both edges of the bouncing axes. - update client implementations to use the `always` content inset behavior. This would require refactoring the ScrollView layout to no longer produce a `contentSize` equal to the ScrollView bounds when using `fittingWidth` and `fittingHeight`. The approach in this PR will enable the safe area behavior we need along a precise edge, like the bottom, while limiting the risk of regressions.
1 parent b455de6 commit f91cf8f

File tree

4 files changed

+563
-55
lines changed

4 files changed

+563
-55
lines changed
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import BlueprintUI
2+
import XCTest
3+
@testable import BlueprintUICommonControls
4+
5+
final class ScrollViewSafeAreaEdgeTests: XCTestCase {
6+
7+
func test_scrollableAxesSafeAreaEdges_givenNoOverlap() throws {
8+
try setupScrollView { controller in
9+
// No contentInset should be applied because the content is within the safe area.
10+
let uiScrollView = try controller.view.expectedChild(ofType: UIScrollView.self)
11+
XCTAssertEqual(uiScrollView.contentInset, .zero)
12+
}
13+
}
14+
15+
func test_scrollableAxesSafeAreaEdges_givenVerticalOverlap() throws {
16+
try setupScrollView { controller in
17+
// Decrease the vertical safe area.
18+
controller.additionalSafeAreaInsets = UIEdgeInsets(top: 10, left: 0, bottom: 10, right: 0)
19+
controller.view.layoutIfNeeded()
20+
21+
// The contentInset should be adjusted to reach the safe area.
22+
let uiScrollView = try controller.view.expectedChild(ofType: UIScrollView.self)
23+
XCTAssertEqual(uiScrollView.contentInset, UIEdgeInsets(top: 10, left: 0, bottom: 10, right: 0))
24+
}
25+
}
26+
27+
func test_scrollableAxesSafeAreaEdges_givenHorizontalOverlap() throws {
28+
try setupScrollView { controller in
29+
// Decrease the horizontal safe area.
30+
controller.additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
31+
controller.view.layoutIfNeeded()
32+
33+
// The contentInset should be adjusted to reach the safe area.
34+
let uiScrollView = try controller.view.expectedChild(ofType: UIScrollView.self)
35+
XCTAssertEqual(uiScrollView.contentInset, UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10))
36+
}
37+
}
38+
39+
func test_scrollableAxesSafeAreaEdges_givenAllOverlap() throws {
40+
try setupScrollView { controller in
41+
// Decrease the vertical safe area.
42+
controller.additionalSafeAreaInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
43+
controller.view.layoutIfNeeded()
44+
45+
// The contentInset should be adjusted to reach the safe area.
46+
let uiScrollView = try controller.view.expectedChild(ofType: UIScrollView.self)
47+
XCTAssertEqual(uiScrollView.contentInset, UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10))
48+
}
49+
}
50+
51+
func test_scrollableAxesSafeAreaEdges_givenBottomOverlap() throws {
52+
try setupScrollView(scrollableAxesSafeAreaEdges: .bottom) { controller in
53+
// Decrease the vertical safe area.
54+
controller.additionalSafeAreaInsets = UIEdgeInsets(top: 10, left: 0, bottom: 10, right: 0)
55+
controller.view.layoutIfNeeded()
56+
57+
// Only the bottom contentInset should be adjusted to reach the safe area.
58+
let uiScrollView = try controller.view.expectedChild(ofType: UIScrollView.self)
59+
XCTAssertEqual(uiScrollView.contentInset, UIEdgeInsets(top: 0, left: 0, bottom: 10, right: 0))
60+
}
61+
}
62+
63+
func test_scrollableAxesSafeAreaEdges_givenRightOverlap() throws {
64+
try setupScrollView(scrollableAxesSafeAreaEdges: .right) { controller in
65+
// Decrease the horizontal safe area.
66+
controller.additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
67+
controller.view.layoutIfNeeded()
68+
69+
// Only the right contentInset should be adjusted to reach the safe area.
70+
let uiScrollView = try controller.view.expectedChild(ofType: UIScrollView.self)
71+
XCTAssertEqual(uiScrollView.contentInset, UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 10))
72+
}
73+
}
74+
75+
func test_scrollableAxesSafeAreaEdges_givenOmittedVerticalOverlap() throws {
76+
try setupScrollView(scrollableAxesSafeAreaEdges: .horizontal) { controller in
77+
// Decrease the safe area on all sides.
78+
controller.additionalSafeAreaInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
79+
controller.view.layoutIfNeeded()
80+
81+
// Only the horizontal contentInset should be adjusted to reach the safe area.
82+
let uiScrollView = try controller.view.expectedChild(ofType: UIScrollView.self)
83+
XCTAssertEqual(uiScrollView.contentInset, UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10))
84+
}
85+
}
86+
87+
func test_scrollableAxesSafeAreaEdges_givenOmittedHorizontalOverlap() throws {
88+
try setupScrollView(scrollableAxesSafeAreaEdges: .vertical) { controller in
89+
// Decrease the safe area on all sides.
90+
controller.additionalSafeAreaInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
91+
controller.view.layoutIfNeeded()
92+
93+
// Only the vertical contentInset should be adjusted to reach the safe area.
94+
let uiScrollView = try controller.view.expectedChild(ofType: UIScrollView.self)
95+
XCTAssertEqual(uiScrollView.contentInset, UIEdgeInsets(top: 10, left: 0, bottom: 10, right: 0))
96+
}
97+
}
98+
99+
func test_scrollableAxesSafeAreaEdges_givenOutsideScrollViewBounds() throws {
100+
try setupScrollView(testSize: CGSize(width: 9999, height: 9999)) { controller in
101+
// No contentInset should be applied because the content is outside the bounds
102+
// of the scroll view.
103+
let uiScrollView = try controller.view.expectedChild(ofType: UIScrollView.self)
104+
XCTAssertEqual(uiScrollView.contentInset, .zero)
105+
}
106+
}
107+
108+
func test_scrollableAxesSafeAreaEdges_givenIgnoredSafeArea() throws {
109+
try setupScrollView(scrollableAxesSafeAreaEdges: []) { controller in
110+
// Decrease the safe area on all sides.
111+
controller.additionalSafeAreaInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
112+
controller.view.layoutIfNeeded()
113+
114+
// No contentInset should be applied because we're ignoring all safe area edges.
115+
let uiScrollView = try controller.view.expectedChild(ofType: UIScrollView.self)
116+
XCTAssertEqual(uiScrollView.contentInset, .zero)
117+
}
118+
}
119+
120+
func setupScrollView(
121+
scrollableAxesSafeAreaEdges: ScrollView.SafeAreaEdge = .all,
122+
testSize: CGSize? = nil,
123+
_ test: (UIViewController
124+
) throws -> Void
125+
) throws {
126+
try show(vc: UIViewController()) { controller in
127+
// Pick a frame for the ScrollView that is completely inside the safe area.
128+
let scrollViewFrame = controller.view.safeAreaLayoutGuide.layoutFrame
129+
130+
// Make the content equal to the ScrollView's bounds by default.
131+
let content = Box().constrainedTo(size: testSize ?? scrollViewFrame.size)
132+
let scrollView = ScrollView(.fittingContent, wrapping: content) { scrollView in
133+
scrollView.contentInsetAdjustmentBehavior = .scrollableAxes
134+
scrollView.scrollableAxesSafeAreaEdges = scrollableAxesSafeAreaEdges
135+
}
136+
let containerView = BlueprintView(element: scrollView)
137+
containerView.frame = scrollViewFrame
138+
controller.view.addSubview(containerView)
139+
containerView.layoutIfNeeded()
140+
141+
try test(controller)
142+
}
143+
}
144+
}

0 commit comments

Comments
 (0)