Skip to content

Commit 83bfcef

Browse files
authored
Fix an issue where resizing scroll views could result in their content offset being changed erroneously (#557)
### Bug https://github.com/user-attachments/assets/7f93f74d-0b54-4131-ac52-8970ec623452 ### Notes 🐛 If you have a partial modal (or any other modal that resizes), and you resize the modal while the keyboard is focused and scrolled away from the top, to be taller (eg by adding content), the scroll view within would jump up towards the top. Westin and I had dug in a while ago and thought it was just a UIKit bug, but it turns out it's an order of operations thing: You need to apply the new `contentSize` to the scroll view before you make its frame taller, otherwise UIKit's automatic `contentOffset` adjustment kicks you back towards the top. To fix this, I've added a `applyBeforeLayout` addition to `ViewDescription.Config`, which will be applied before the `LayoutAttributes` (except on initial view allocation – we still apply the attributes first so the view's properties are sensical).
1 parent 2334742 commit 83bfcef

File tree

5 files changed

+84
-17
lines changed

5 files changed

+84
-17
lines changed

BlueprintUI/Sources/BlueprintView/BlueprintView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,7 @@ extension BlueprintView {
709709
layoutTransition = .inherited
710710
}
711711
layoutTransition.perform {
712+
child.viewDescription.applyBeforeLayout(to: controller.view)
712713
child.layoutAttributes.apply(to: controller.view)
713714

714715
if pathsChanged {
@@ -729,6 +730,8 @@ extension BlueprintView {
729730
UIView.performWithoutAnimation {
730731
controller = NativeViewController(node: child)
731732
child.layoutAttributes.apply(to: controller.view)
733+
// So the view has a reasonable size during creation/allocation, do this afterwards.
734+
child.viewDescription.applyBeforeLayout(to: controller.view)
732735

733736
contentView.insertSubview(controller.view, at: index)
734737

BlueprintUI/Sources/ViewDescription/ViewDescription.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public struct ViewDescription {
4343
private let _viewType: UIView.Type
4444
private let _build: () -> UIView
4545
private let _apply: (UIView) -> Void
46+
private let _applyBeforeLayout: (UIView) -> Void
4647
private let _contentView: (UIView) -> UIView
4748

4849
private let _layoutTransition: LayoutTransition
@@ -86,6 +87,16 @@ public struct ViewDescription {
8687
}
8788
}
8889

90+
_applyBeforeLayout = { view in
91+
let typedView = configuration.typeChecked(view: view)
92+
for update in configuration.updatesBeforeLayout {
93+
update(typedView)
94+
}
95+
for binding in configuration.bindings {
96+
binding.value.apply(to: typedView)
97+
}
98+
}
99+
89100
_contentView = { view in
90101
let typedView = configuration.typeChecked(view: view)
91102
return configuration.contentView(typedView)
@@ -109,6 +120,10 @@ public struct ViewDescription {
109120
_build()
110121
}
111122

123+
public func applyBeforeLayout(to view: UIView) {
124+
_applyBeforeLayout(view)
125+
}
126+
112127
public func apply(to view: UIView) {
113128
_apply(view)
114129
}
@@ -154,9 +169,12 @@ extension ViewDescription {
154169
/// The default value instantiates the view using `init(frame:)`.
155170
public var builder: () -> View
156171

157-
/// An array of update closures.
172+
/// An array of update closures that are applied **after** the `LayoutAttributes` is applied to the view.
158173
public var updates: [Update]
159174

175+
/// An array of update closures that are applied **before** the `LayoutAttributes` is applied to the view.
176+
public var updatesBeforeLayout: [Update]
177+
160178
/// A closure that takes a native view instance as the single argument, and
161179
/// returns a subview of that view into which child views should be added
162180
/// and managed.
@@ -201,6 +219,7 @@ extension ViewDescription {
201219
public init() {
202220
builder = { View(frame: .zero) }
203221
updates = []
222+
updatesBeforeLayout = []
204223
contentView = { $0 }
205224
}
206225

@@ -217,11 +236,24 @@ extension ViewDescription {
217236

218237
extension ViewDescription.Configuration {
219238

220-
/// Adds the given update closure to the `updates` array.
239+
/// Adds the given update closure to the `updates` array,
240+
/// and applies them **after** the `LayoutAttributes` is applied to the view.
221241
public mutating func apply(_ update: @escaping Update) {
222242
updates.append(update)
223243
}
224244

245+
/// Adds the given update closure to the `updates` array,
246+
/// and applies them **before** the `LayoutAttributes` is applied to the view.
247+
///
248+
/// > During view allocation and creation, this closure is applied after initial `LayoutAttributes`
249+
/// are applied, so the view has reasonable default values.
250+
///
251+
/// > You should rarely need to use this method, unless you need to apply an update to the view
252+
/// before one of the properties on set by `LayoutAttributes` is set.
253+
public mutating func applyBeforeLayout(_ update: @escaping Update) {
254+
updatesBeforeLayout.append(update)
255+
}
256+
225257
/// Subscript for values that are not optional. We must represent these values as optional so that we can
226258
/// return nil from the subscript in the case where no value has been assigned for the given keypath.
227259
///

BlueprintUI/Tests/ViewDescriptionTests.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,24 @@ class ViewDescriptionTests: XCTestCase {
3131
XCTAssertEqual(view.mutableProperty, "123")
3232
}
3333

34+
func test_applyBeforeLayout() {
35+
let description = TestView.describe { config in
36+
config.applyBeforeLayout {
37+
$0.mutableProperty = "testing"
38+
}
39+
}
40+
41+
let view = description.build() as! TestView
42+
description.applyBeforeLayout(to: view)
43+
XCTAssertEqual(view.mutableProperty, "testing")
44+
45+
let secondDescription = TestView.describe { config in
46+
config.applyBeforeLayout { $0.mutableProperty = "123" }
47+
}
48+
secondDescription.applyBeforeLayout(to: view)
49+
XCTAssertEqual(view.mutableProperty, "123")
50+
}
51+
3452
func test_bind() {
3553
let description = TestView.describe { config in
3654
config[\.mutableProperty] = "testing"

BlueprintUICommonControls/Sources/ScrollView.swift

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,34 @@ public struct ScrollView: Element {
5757

5858
config.contentView = { $0.scrollView }
5959

60-
config.apply {
61-
$0.apply(scrollView: self, contentFrame: context.subtreeExtent ?? .zero)
60+
let contentFrame = context.subtreeExtent ?? .zero
61+
62+
config.applyBeforeLayout { view in
63+
64+
/// We apply the `contentSize` before the `LayoutAttributes` because order of operations matters
65+
/// when adjusting the size of the scroll view's frame and the content size. If we set the frame before
66+
/// setting the content size – in particular making the frame taller before adjusting the content size,
67+
/// the scroll view will internally adjust the `contentOffset`, resulting in the on-screen content
68+
/// jumping around.
69+
///
70+
/// This is most visible and annoying when you have a scroll view in a resizable modal, which is scrolled away
71+
/// from the top of its content. If the size of the scroll view grows before the `contentSize` is adjusted,
72+
/// the visible content will shift.
73+
74+
let contentSize = switch contentSize {
75+
case .fittingWidth, .fittingHeight, .fittingContent:
76+
CGSize(width: contentFrame.maxX, height: contentFrame.maxY)
77+
case .custom(let custom):
78+
custom
79+
}
80+
81+
if view.scrollView.contentSize != contentSize {
82+
view.scrollView.contentSize = contentSize
83+
}
84+
}
85+
86+
config.apply { view in
87+
view.apply(scrollView: self, contentFrame: contentFrame)
6288
}
6389
}
6490
}
@@ -346,15 +372,6 @@ fileprivate final class ScrollerWrapperView: UIView {
346372
}
347373
}
348374

349-
let contentSize: CGSize
350-
351-
switch scrollView.contentSize {
352-
case .fittingWidth, .fittingHeight, .fittingContent:
353-
contentSize = CGSize(width: contentFrame.maxX, height: contentFrame.maxY)
354-
case .custom(let customSize):
355-
contentSize = customSize
356-
}
357-
358375
if self.scrollView.alwaysBounceHorizontal != scrollView.alwaysBounceHorizontal {
359376
self.scrollView.alwaysBounceHorizontal = scrollView.alwaysBounceHorizontal
360377
}
@@ -363,10 +380,6 @@ fileprivate final class ScrollerWrapperView: UIView {
363380
self.scrollView.alwaysBounceVertical = scrollView.alwaysBounceVertical
364381
}
365382

366-
if self.scrollView.contentSize != contentSize {
367-
self.scrollView.contentSize = contentSize
368-
}
369-
370383
if self.scrollView.showsVerticalScrollIndicator != scrollView.showsVerticalScrollIndicator {
371384
self.scrollView.showsVerticalScrollIndicator = scrollView.showsVerticalScrollIndicator
372385
}

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- 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.
1314

1415
### Added
1516

0 commit comments

Comments
 (0)