DEV Community

Артем Калинин
Артем Калинин

Posted on

Creating UITableView with a dynamic header

Hello there! Recently, I had very cool experience at my work. I needed to set tableView with a dynamic header. The information in the header was complete in the initial state, and when the user was scrolling the table, some part in the header was smoothly hiding and the main part remained on the top. Cool, right?

I have created two files HeaderView and, of course, ViewController.

First of all, let’s take a look at HeaderView. I have added three views with different colors as an example in UIStackView in HeaderView. All the magic with a smooth hidden and alpha of this objects will be here. Also, we have var height. In the observer (didSet) we will calculate an actual height of our HeaderView and make the colored views invisible or not.

private lazy var blueView: UIView = {
        let view = UIView()
        view.backgroundColor = .blue
        view.heightAnchor.constraint(equalToConstant: 72).isActive = true
        return view
    }()

    private lazy var greenView: UIView = {
        let view = UIView()
        view.backgroundColor = .green
        view.heightAnchor.constraint(equalToConstant: 72).isActive = true
        return view
    }()

    private lazy var yellowView: UIView = {
        let view = UIView()
        view.backgroundColor = .yellow
        view.heightAnchor.constraint(equalToConstant: 72).isActive = true
        return view
    }()

    private let stackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.spacing = 16
        stackView.translatesAutoresizingMaskIntoConstraints = false
        return stackView
    }()

 var height: CGFloat = 340 {
        didSet {
            let diagramAlpha = 1.0 - (maxHeight - height > 140.0 ? 140.0 : maxHeight - height) / 140.0
            blueView.alpha = diagramAlpha
            yellowView.alpha = diagramAlpha

            if diagramAlpha < 0.3 {
                blueView.isHidden = true
                yellowView.isHidden = true
            } else {
                blueView.isHidden = false
                yellowView.isHidden = false
            }
            layoutIfNeeded()
        }
    }
Enter fullscreen mode Exit fullscreen mode

In HeaderView I have min/max Height variable we needed to set actual value from view controller

var maxHeight: CGFloat = 340
var minHeight: CGFloat = 110
Enter fullscreen mode Exit fullscreen mode

The next step – we are going to the controller. I am creating the usual UITableView and adding our HeaderView. For the both objects I am setting topAnchor = view.topAnchor

Then I am creating three methods that will do all the magic.

private func calculateHeaderViewHeight(for currentOffset: CGFloat) {
        if currentOffset <= 0 {
            setHeaderViewHeight(for: headerView.maxHeight)
        } else {
            var newHeight = headerView.maxHeight - currentOffset
            if newHeight < headerView.minHeight {
                newHeight = headerView.minHeight
            }
            setHeaderViewHeight(for: newHeight)
        }
    }

    private func setHeaderViewHeight(for newHeight: CGFloat) {
        if headerViewHeightConstraint?.constant != newHeight {
            headerViewHeightConstraint?.constant = newHeight
            headerView.height = newHeight
        }
    }

    private func changeHeaderStateIfNeeded() {
        var offset = CGPoint(x: 0, y: -480)
        var tableContentInset: UIEdgeInsets = .zero
        offset = CGPoint(x: 0, y: -480)
        tableContentInset.top = 330
        tableView.contentInset = tableContentInset
        tableView.setContentOffset(offset, animated: true)
        setHeaderViewHeight(for: headerView.maxHeight)
        view.layoutIfNeeded()
    }
Enter fullscreen mode Exit fullscreen mode

And I am adding calculateHeaderViewHeight in func scrollViewDidScroll(_ scrollView: UIScrollView) that will observe contentInset and contentOffset and set the necessary state.

func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let currentOffset = scrollView.contentOffset.y + scrollView.contentInset.top
        calculateHeaderViewHeight(for: currentOffset)
    }
Enter fullscreen mode Exit fullscreen mode

And last but not least if you want to add an automatic and smooth transition for your tableView, just add this code.

COPY
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        let currentOffset = scrollView.contentOffset.y + scrollView.contentInset.top
        var offset: CGPoint = .zero

        let transition = UIViewPropertyAnimator(duration: 0.0, dampingRatio: 1) {
            if currentOffset < 170 {
                offset.y = -300
            } else {
                guard currentOffset < 276 else { return }
                offset.y = -230
            }
            DispatchQueue.main.async {
                self.tableView.setContentOffset(offset, animated: true)
            }
        }
        transition.startAnimation()
    }
Enter fullscreen mode Exit fullscreen mode

And don’t forget the code that we need to write for the moment when we will stop dragging our tableView.

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        targetContentOffset.pointee.y = max(targetContentOffset.pointee.y - 1, 1)
    }
Enter fullscreen mode Exit fullscreen mode

Let's see what we have finally got in ViewController.

import UIKit

class ViewController: UIViewController {
    private var headerViewHeightConstraint: NSLayoutConstraint?

    private lazy var headerView: HeaderView = {
        let view = HeaderView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.backgroundColor = .white
        return view
    }()

    private lazy var tableView: UITableView = {
        let tableView = UITableView(frame: .zero, style: .grouped)
        tableView.separatorStyle = .none
        tableView.delegate = self
        tableView.dataSource = self
        tableView.showsVerticalScrollIndicator = false
        tableView.backgroundColor = .gray
        tableView.translatesAutoresizingMaskIntoConstraints = false
        return tableView
    }()

    var numbersArray = ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"]

    // MARK: - Life cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }

    // MARK: Private
    private func calculateHeaderViewHeight(for currentOffset: CGFloat) {
        if currentOffset <= 0 {
            setHeaderViewHeight(for: headerView.maxHeight)
        } else {
            var newHeight = headerView.maxHeight - currentOffset
            if newHeight < headerView.minHeight {
                newHeight = headerView.minHeight
            }
            setHeaderViewHeight(for: newHeight)
        }
    }

    private func setHeaderViewHeight(for newHeight: CGFloat) {
        if headerViewHeightConstraint?.constant != newHeight {
            headerViewHeightConstraint?.constant = newHeight
            headerView.height = newHeight
        }
    }

    private func changeHeaderStateIfNeeded() {
        var offset = CGPoint(x: 0, y: -480)
        var tableContentInset: UIEdgeInsets = .zero
        offset = CGPoint(x: 0, y: -480)
        tableContentInset.top = 330
        tableView.contentInset = tableContentInset
        tableView.setContentOffset(offset, animated: true)
        setHeaderViewHeight(for: headerView.maxHeight)
        view.layoutIfNeeded()
    }
}

// MARK: SetupUI
extension ViewController {
    private func setupUI() {
        view.addSubview(tableView)
        view.addSubview(headerView)

        view.backgroundColor = .white
        let headerHeightConstraint = headerView.heightAnchor.constraint(equalToConstant: headerView.maxHeight)
        self.headerViewHeightConstraint = headerHeightConstraint

        NSLayoutConstraint.activate([
            headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            headerHeightConstraint,

            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
            tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])

        changeHeaderStateIfNeeded()
    }
}

// MARK: UITableViewDelegate
extension ViewController: UITableViewDataSource, UITableViewDelegate {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return numbersArray.count
    }

    func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
        return nil
    }

    func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
        return .leastNormalMagnitude
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        var contentConfiguration = UIListContentConfiguration.sidebarCell()
        contentConfiguration.text = numbersArray[indexPath.row]
        cell.contentConfiguration = contentConfiguration
        cell.backgroundColor = .gray
        return cell
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 64
    }
}

// MARK: UIScrollView
extension ViewController {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let currentOffset = scrollView.contentOffset.y + scrollView.contentInset.top
        calculateHeaderViewHeight(for: currentOffset)
    }

    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        let currentOffset = scrollView.contentOffset.y + scrollView.contentInset.top
        var offset: CGPoint = .zero

        let transition = UIViewPropertyAnimator(duration: 0.0, dampingRatio: 1) {
            if currentOffset < 170 {
                offset.y = -300
            } else {
                guard currentOffset < 276 else { return }
                offset.y = -230
            }
            DispatchQueue.main.async {
                self.tableView.setContentOffset(offset, animated: true)
            }
        }
        transition.startAnimation()
    }

    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        targetContentOffset.pointee.y = max(targetContentOffset.pointee.y - 1, 1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Image description

I hope you have enjoyed this article and it has been useful for you. Thanx for reading! And Merry Christmas and Happy New Year!

Top comments (0)