Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Swift - Sticky header with UIScrollView

Tags:

ios

swift

I'm trying to develop a header with a scrollView a bit like the Revolut app:

Revolut app screen recording

I was thinking of adding one scrollview into another but I guess wouldn't get the same result.

Here is my code:

import UIKit
import SnapKit

class ItemDetailViewController: UIViewController, UIScrollViewDelegate {
        
        
        lazy var topBar = CryptoDetailTopBarView()
        lazy var header = UIView()
        lazy var chartView = UIView()
        
        lazy var scrollView: UIScrollView = {
            let view = UIScrollView()
            view.contentSize = CGSize(width: 414, height: 2000)
            view.backgroundColor = .systemGreen
            view.bounces = true
            return view
        }()
        
        lazy var name = UILabel()
        lazy var symbol = UILabel()
        lazy var type = UILabel()
        
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemGray
            
            view.addSubview(topBar)
            view.addSubview(header)
            view.addSubview(scrollView)
            
            header.addSubview(name)
            header.addSubview(symbol)
            header.addSubview(type)
            
            scrollView.addSubview(chartView)
            
            topBar.layer.zPosition = 20
            topBar.backgroundColor = .blue
            
            header.backgroundColor = .systemRed
            
            name.textColor = .label
            name.font = .boldSystemFont(ofSize: 34)
            
            symbol.textColor = .label
            type.textColor = .systemGray
            
            
            chartView.backgroundColor = .white
            chartView.isUserInteractionEnabled = true
    
    
            self.topBar.title.text = "Title"
            self.name.text = "Title"
            self.symbol.text = "Text"
            self.type.text = "Type"
    
            
            scrollView.delegate = self
            
            updateViewConstraints()
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            
            if header.frame.minY < topBar.frame.maxY - name.frame.minY - name.frame.height/2 {
                topBar.title.alpha = 1
            } else {
                topBar.title.alpha = 0
            }
            
            let y: CGFloat = scrollView.contentOffset.y
            
            header.snp.remakeConstraints { make in
                make.left.equalToSuperview()
                make.top.equalTo(topBar.snp.bottom).inset(y)
                make.width.equalToSuperview()
                make.height.equalTo(150)
            }
            
        }
        
        
        override func updateViewConstraints() {
            
            topBar.snp.makeConstraints { make in
                make.left.top.equalToSuperview()
                make.width.equalToSuperview()
                make.height.equalTo(90)
            }
            
            header.snp.makeConstraints { make in
                make.left.equalToSuperview()
                make.top.equalTo(topBar.snp.bottom)
                make.width.equalToSuperview()
                make.height.equalTo(150)
            }
            
            scrollView.snp.makeConstraints { make in
                make.top.equalTo(header.snp.bottom)
                make.centerX.bottom.width.equalToSuperview()
            }
            
            name.snp.makeConstraints { make in
                make.top.equalToSuperview().offset(10)
                make.left.equalToSuperview().offset(16)
                make.width.equalTo(200)
                make.height.equalTo(41)
            }
            
            symbol.snp.makeConstraints { make in
                make.top.equalTo(name.snp.bottom).offset(10)
                make.left.equalTo(name)
                make.width.equalTo(50)
                make.height.equalTo(20)
            }
            
            type.snp.makeConstraints { make in
                make.top.equalTo(symbol)
                make.left.equalTo(symbol.snp.right).offset(10)
                make.width.equalTo(50)
                make.height.equalTo(20)
            }
            
            chartView.snp.makeConstraints { make in
                make.left.equalTo(scrollView).offset(16)
                make.width.equalTo(scrollView).offset(-32)
                make.top.equalTo(15)
                make.height.equalTo(100)
            }
            
            
            super.updateViewConstraints()
        }
        
        fileprivate func getHeightOfHeaderView() -> CGFloat {
            return header.frame.height
        }
        
    }

The topBar view is the view with the smaller title that replace the big title when I scroll down (like Revolut).

I'm updating constraints using SnapKit depending on my scrollView offset.

Here is what I get:

My app screen recording

You can see that my white view called chartView (part of the scrollView) is moving faster than my header which remove the "pushing" effect there is on Revolut.

Also, my white view is stuck when I drag down the scrollView: there should be a "pulling down" effect.

Basically I want to make the same thing as Revolut. The App Store has the pretty same header for non featured app.

I tried with collectionView, tableView and scrollView (using navigation bar big title) but it's not doing what I want. I need to make it myself from scratch like Revolut did I think.

Btw, you can easily execute my code because there is no storyboard needed.

like image 884
Paul Bénéteau Avatar asked Oct 14 '25 08:10

Paul Bénéteau


1 Answers

I've implemented something like this a couple of times and you're really close.

Reacting to scrollViewDidScroll is the right thing to do, but I wouldn't recommend updating the constraints. I would't say that Auto Layout was designed to be updated in real time, and even if it worked correctly, you'll probably see a performance impact.

Instead, use the transform property of your view.

You'll have to tweak the values but it should look a bit like this:

header.transform = CGAffineTransform(translateX: 0, y: max(0, scrollView.contentOffset.y - header.bounds.y))

The reasoning for this math is simpler than it seems:

We want our view to be moved downwards as soon as the content offset reaches our view's position. This happens when scrollView.contentOffset.y - header.bounds.y == 0. Before this happens, this number will be lower than 0, that's why we use max. This way, before this happens, we'll apply a transform with a translation of 0, so our view will be dragged normally.

Once our scroll view has been dragged beyond the point where our view is at the top, that's when we want to make it sticky. scrollView.contentOffset.y - header.bounds.y tells us how far beyond our view the user has scrolled, so move our view down by exactly that value.

If you are using a view with content insets, you might need to tweak your math, it might look something like this, but if it doesn't work I'll leave it up to you to play with the values to see what works.

header.transform = CGAffineTransform(translateX: 0, y: max(0, scrollView.contentOffset.y - (header.bounds.y + scrollView.adjustedContentInsets.top)))

Tip: Use a print statement with values like the content offset on scrollViewDidScroll in order to figure out what numbers you want exactly.

like image 155
EmilioPelaez Avatar answered Oct 16 '25 20:10

EmilioPelaez