4

I'm trying to create a complex sticky UICollectionView header:

  • it must be resizable based on specific scroll criteria, e.g. when a user scrolls past a certain y position, the header will resize.
  • it must automatically resize while the collection view content is scrolling
  • it must respond to touch events on the header itself (it can't be a background view that "appears" like a header)

Now this has proven to be quite the challenge, but I've made some progress. To simplify the problem, I'm starting by just creating a header that will resize on scroll and keep its bounds when the touch ends.

To get started, I've created a collection view and supplementary view header that looks like this in its simplest form:

private let defaultHeaderViewHeight: CGFloat = 250.0
private var beginHeaderViewHeight: CGFloat = defaultHeaderViewHeight
private var currentHeaderViewHeight: CGFloat = defaultHeaderViewHeight

private var headerView: HeaderView? {
    get {
        guard let headerView = collectionView?.supplementaryView(forElementKind: UICollectionElementKindSectionHeader, at: IndexPath(row: 0, section: 0)) as? HeaderView else { return nil }
        return headerView
    }
}

// Resizing logic

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
    // Define the header size. This is called each time the layout is invalidated, and the beginHeaderViewHeight value is different each time
    print("Header ref size delegate called, setting height to \(beginHeaderViewHeight)")
    return CGSize(width: collectionView.frame.size.width, height: beginHeaderViewHeight)
    //                                                            ^ super important!
}

override func scrollViewDidScroll(_ scrollView: UIScrollView) {

    currentHeaderViewHeight = max(0, -(collectionView?.contentOffset.y)! + beginHeaderViewHeight)

    headerView?.frame.size.height = currentHeaderViewHeight
    headerView?.frame.origin.y = collectionView?.contentOffset.y ?? 0

    print("Did scroll\t\t\tcurrentHeaderViewHeight: \(currentHeaderViewHeight), content offset: \(scrollView.contentOffset.y)")
}

override func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {

    print("Will end dragging\t\theader view frame height: \(headerView?.frame.size.height), content offset: \(scrollView.contentOffset.y)")

    scrollView.bounds.origin = CGPoint(x: 0, y: 0)

    self.collectionView?.collectionViewLayout.invalidateLayout()
    print("Will end dragging\t\theader view frame height: \(headerView?.frame.size.height), content offset: \(scrollView.contentOffset.y)")
    beginHeaderViewHeight = currentHeaderViewHeight

}

// Basic UICollectionView setup

override func viewDidLoad() {
    super.viewDidLoad()

    collectionView?.register(UINib(nibName: "HeaderView", bundle: nil), forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, withReuseIdentifier: "Header View")

}

override func numberOfSections(in collectionView: UICollectionView) -> Int {
    return 1
}

override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return 50
}

override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath)
    return cell
}

This kinda works, but not entirely. When I scroll "up" (touch tracks down), it behaves as I'd expect - when the touch ends, the header resizes and sticks in place. Here's a log extract to validate:

Did scroll          currentHeaderViewHeight: 447.333333333333, content offset: -70.3333333333333
Did scroll          currentHeaderViewHeight: 449.0, content offset: -72.0
Did scroll          currentHeaderViewHeight: 451.0, content offset: -74.0
Did scroll          currentHeaderViewHeight: 451.666666666667, content offset: -74.6666666666667
Will end dragging   header view frame height: Optional(451.666666666667), content offset: -74.6666666666667
Will end dragging   header view frame height: Optional(451.666666666667), content offset: 0.0
Did end dragging    currentHeaderViewHeight: 451.666666666667, content offset: 0.0
Header ref size delegate called, setting height to 451.666666666667

But when I scroll "down" (touch tracks up), it gets really goofed: it appears that scrollViewDidScroll is being called, even though I'm not setting the content offset anywhere:

Did scroll          currentHeaderViewHeight: 330.666666666667, content offset: 121.0
Did scroll          currentHeaderViewHeight: 330.333333333333, content offset: 121.333333333333
Did scroll          currentHeaderViewHeight: 330.0, content offset: 121.666666666667
Did scroll          currentHeaderViewHeight: 328.333333333333, content offset: 123.333333333333
Will end dragging   header view frame height: Optional(328.333333333333), content offset: 123.333333333333
Will end dragging   header view frame height: Optional(328.333333333333), content offset: 0.0
Did end dragging    currentHeaderViewHeight: 328.333333333333, content offset: 0.0
Header ref size delegate called, setting height to 328.333333333333
// WHY ARE THESE LAST TWO LINES CALLED?
Did scroll          currentHeaderViewHeight: 205.0, content offset: 123.333333333333
Did end deceler..   currentHeaderViewHeight: 205.0, content offset: 123.333333333333

So the tl;dr here: why on earth does it work when I scroll in one direction and not the other?

Bonus points: If there's a cleaner, simpler way to do all of this, feel free to share!

brandonscript
  • 68,675
  • 32
  • 163
  • 220

0 Answers0