Making two header sections sticky on UITableView

108 Views Asked by At

I researched quite a bit but just couldn't find the answer to my question.

I have created a table (style: .grouped) in Xcode with two headers I want to make sticky. The upper one should collapse on scrolling until the height of the others cells is reached, the lower one should just stay like it is – so pretty similar to the behaviour right now (see below) – just sticking to the top.

Table Preview

My code is as follows:

  1. HomeTableViewController.swift
import UIKit

class HomeTableViewController: UITableViewController {
    
    var headerView: HeaderView = {
        let nib = UINib(nibName: "HeaderView", bundle: nil)
        return nib.instantiate(withOwner: HomeTableViewController.self, options: nil).first as! HeaderView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        tableView.delegate = self
        tableView.dataSource = self
        
        // Uncomment the following line to preserve selection between presentations
        // self.clearsSelectionOnViewWillAppear = false
        
        // Uncomment the following line to display an Edit button in the navigation bar for this view controller.
        // self.navigationItem.rightBarButtonItem = self.editButtonItem
                
        headerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        headerView.titleLabel.text = "Title"
        headerView.scrollView = tableView
        headerView.frame = CGRect(
            x: 0,
            y: tableView.safeAreaInsets.top,
            width: view.frame.width,
            height: 250)
        
        tableView.backgroundView = UIView()
        tableView.backgroundView?.addSubview(headerView)
        tableView.contentInset = UIEdgeInsets(
            top: 250,
            left: 0,
            bottom: 0,
            right: 0)
    }
    
    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()
        tableView.contentInset = UIEdgeInsets(top: 250 + tableView.safeAreaInsets.top,
                                              left: 0,
                                              bottom: 0,
                                              right: 0)
        headerView.updatePosition()
    }
    
    override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        let sectionHeaderLabelView = UIView()
        sectionHeaderLabelView.backgroundColor = .systemBackground

        let sectionHeaderLabel = UILabel()
        sectionHeaderLabel.text = "Subtitle"
        sectionHeaderLabel.textColor = .label
        sectionHeaderLabel.font = UIFont.systemFont(ofSize: 20.0, weight: .semibold)
        sectionHeaderLabel.frame = CGRect(x: view.frame.width * 0.05, y: 0, width: view.frame.width, height: 58)
        sectionHeaderLabelView.addSubview(sectionHeaderLabel)
        
        let sectionHeaderButton = UIButton()
        sectionHeaderButton.frame = CGRect(x: view.frame.width * 0.58, y: 0, width: view.frame.width * 0.5, height: 58)
        sectionHeaderButton.setTitle("alle anzeigen →", for: .normal)
        sectionHeaderButton.setTitleColor(.secondaryLabel, for: .normal)
        sectionHeaderButton.titleLabel?.font = UIFont.systemFont(ofSize: 15.0, weight: .semibold)
        sectionHeaderButton.addTarget(self, action: #selector(buttonAction), for: .touchUpInside)
        sectionHeaderLabelView.addSubview(sectionHeaderButton)

        return sectionHeaderLabelView
    }
    
    @objc func buttonAction(sender: UIButton!) {
        print("Button tapped")
    }
    
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        headerView.updatePosition()
    }
    
    // MARK: Table view data source
    override func numberOfSections(in tableView: UITableView) -> Int {
        // #warning Incomplete implementation, return the number of sections
        return 1
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // #warning Incomplete implementation, return the number of rows
        return 20
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "providerCell", for: indexPath) as! HomeTableViewCell
        
        [...]
        
        return cell
    }
    
    [...]
    
}
  1. HeaderView.swift (just for the collapsing "top" header)
import Foundation
import UIKit

class HeaderView: UIView {
    @IBOutlet private(set) var titleLabel: UILabel!
    @IBOutlet weak var topBlurView: UIVisualEffectView!
    @IBOutlet weak var bottomBlurView: UIVisualEffectView!
    @IBOutlet weak var scrollView: UIScrollView?
    private var cachedMinimumSize: CGSize?
    
    override func layoutSubviews() {
        super.layoutSubviews()

        topBlurView.effect = .none
        bottomBlurView.effect = .none
    }
    
    // Calculate and cache the minimum size the header's constraints will fit.
    // This value is cached since it can be a costly calculation to make, and
    // we want to keep the framerate high.
    private var minimumHeight: CGFloat {
        get {
            guard let scrollView = scrollView else { return 0 }
            if let cachedSize = cachedMinimumSize {
                if cachedSize.width == scrollView.frame.width {
                    return cachedSize.height
                }
            }
         
            // Ask Auto Layout what the minimum height of the header should be.
            let minimumSize = systemLayoutSizeFitting(CGSize(width: scrollView.frame.width, height: 0),
                                                      withHorizontalFittingPriority: .required,
                                                      verticalFittingPriority: .defaultLow)
            cachedMinimumSize = minimumSize
            return minimumSize.height
        }
    }

    func updatePosition() {
        guard let scrollView = scrollView else { return }
        
        // Calculate the minimum size the header's constraints will fit
        let minimumSize = minimumHeight
        
        // Calculate the baseline header height and vertical position
        let referenceOffset = scrollView.safeAreaInsets.top
        let referenceHeight = scrollView.contentInset.top - referenceOffset
        
        // Calculate the new frame size and position
        let offset = referenceHeight + scrollView.contentOffset.y
        let targetHeight = referenceHeight - offset - referenceOffset
        var targetOffset = referenceOffset
        if targetHeight < minimumSize {
            targetOffset += targetHeight - minimumSize
        }
        
        // Update the header's height and vertical position.
        var headerFrame = frame;
        headerFrame.size.height = max(minimumSize, targetHeight)
        headerFrame.origin.y = targetOffset
        
        frame = headerFrame;
    }
}

Could anyone assist kindly on how to make these two stick to the top when scrolling?

All the best!

0

There are 0 best solutions below