I have a very annoying detail problem that I don’t know how to solve properly with UIScrollView.
I have base UIViewController class with a scrollView and contentView set up like this:
import UIKit
/*
Controller used where content will be scrollable once it extends past the size of the screen,
for example when the keyboard is shown.
This controller will automatically avoid the keyboard if needed.
For this to scroll properly you need to either create a constraint between the last child view
and the bottom of the contentView or constraint the contentView height with a low/medium priority.
When using this as the parent controller use the `contentView` property instead of `view`
when adding content.
*/
class ScrollableContentController: KeyboardAwareViewController {
private class ScrollView: UIScrollView {
convenience init() {
self.init(frame: .init())
translatesAutoresizingMaskIntoConstraints = false
keyboardDismissMode = .interactive
delaysContentTouches = false
}
override func touchesShouldCancel(in view: UIView) -> Bool { true }
}
let scrollView: UIScrollView = ScrollView()
let contentView = UIView()
override func loadView() {
super.loadView()
contentView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
scrollView.addSubview(contentView)
NSLayoutConstraint.activate([
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
contentView.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor),
])
scrollView.delegate = self
}
override func keyboardWillChangeFrame(_ frame: CGRect) {
if isTopViewController {
scrollView.contentInset.bottom = frame.height
}
}
}
I’ve until now used this class by constraining my bottommost view’s bottom to contentView’s bottom. This will lead to the scrollview being scrollable only once the contentSize exceeds the scrollview’s size. However I recently started getting annoyed by views that does not bounce because they are small. Forexample if you navigate through System Settings app you will notice that all views will bounce no matter if it’s needed or not. Maybe this is usually done with a UICollectionView or UITableView but I hate using those when I have a low and predefined number of views so I insist on using a UIScrollView instead.
I figured I can probably mimic this bouncing behaviour by setting the contentSize to being at least 0.5px higher than my safeArea height, like this:
let height = view.safeAreaLayoutGuide.layoutFrame.size.height.roundUpToNearestHalf()
contentView.snp.updateConstraints { make in
make.height.equalTo(max(section.maxY, height)).priority(500)
}
And it works very fine! well almost..
The very annyoing problem is that when I scroll down and it bounces back up, it will bounce back to the initial position, which on my phone and in my case happens to be (0.0, -97.66666666666667). I get that info in the scrollViewDidEndDecelerating delegate method.
However when I scroll up and it bounces back down, it will land on (0.0, -97.33333333333333). It’s very hard to see but causes the bottom border of the navigation view to never fully disappear, and it’s really annoying once you can see it. This problem does ofc not exist in the System Settings app and you will see the bottom border of the navigation bar fade out completely.
How can I fix this problem without using a Table or CollectionView? Also, is it even reasonable that the contentOffset is negative or have I done everything completely wrong to begin with?
I’ve hacked around it right now by saving the initial content offset in viewLayoutMarginsDidChange and then in scrollViewDidEndDecelerating checking if the difference is less than 1 just set it to initial position like:
if abs(scrollView.contentOffset.y - initialContentOffset.y) < 1 {
scrollView.setContentOffset(initialContentOffset, animated: true)
}
But I hope there is a better solution, this is hack! BTW, shouldn’t initial content offset be .zero?
2
Answers
Setting the content size to be slightly larger than the safe area is a clever way to ensure there’s always something to scroll. Your approach seems reasonable, but the issue with the precise bounce-back position might be related to how the constraints are being set up and how the safe area is calculated.
And ensure that you are correctly accounting for the safe area insets, especially if you are dealing with devices with notches or different screen sizes.
Answering as per OP’s comment…
By default, a
UIScrollView
will not "bounce" if there is not enough content to require scrolling.We can set:
to always get the bounce behavior.