skip to Main Content

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


  1. 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.

    Login or Signup to reply.
  2. 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:

    scrollView.alwaysBounceVertical = true
    
    // and/or as desired
    
    scrollView.alwaysBounceHorizontal = true
    

    to always get the bounce behavior.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search