Implementing Sticky Cell in UICollectionView or UITableView

Viewed 1180

I am to implement a table with a list of items which includes one item that should always be onscreen. So, for example:

  • you have 50 items in the list
  • your "sticky" list item is 25th
  • you have 10 items that may be displayed onscreen at a time
  • despite of you position in the list, "sticky" list should always remain visible
  • if your item is lower than your position in the list it is displayed on the bottom of the list
  • if your item is between previous items it should be displayed on the top of the list
  • as soon as you reach you item's real position in the list, it should move together with the scroll of the list

Here are the illustrations to better understand the implementation requirements:

3 screens. On 1st screen, 25th cell is stuck at bottom, overlapping other cells underneath. On 2nd screen, 25th screen is inline with the other cells. On 3rd screen, 25th cell is stuck at the top.

Will be glad for any possible ideas, suggestions or recommendations on how can this possibly implemented. Unfortunately, I failed to find any useful libraries or solutions that solve this problem. UICollectionView and UITableView are both acceptable for this case.

Sticky header or footer, as per my understanding do not work in this case as they cover only half of the functionality that I need.

Thank you in advance for your comments and answers!!!

1 Answers

I'm pretty sure you can't actually have the same actual cell be sticky like that. You can create the illusion of stickiness through auto layout trickery though. Basically, my suggestion is that you can have views that are the same as your cells that you want to be "sticky" and constrain them on top of your sticky cells while your sticky cells are visible. The best I could pull off on this doesn't look quite perfect if you scroll slowly. (The sticky cell goes mostly off screen before snapping to the top or bottom position. It isn't noticeable in my opinion at fairly normal scrolling speeds. Your mileage may vary.)

The key is setting up a table view delegate so you can get notified about when the cell will or will not be on the screen.

I've included an example view controller. I'm sure there are areas where my example code won't work. (For example, I didn't handle stacking multiple "sticky" cells, or dynamic row heights. Also, I made my sticky cell blue so it would be easier to see the stickiness.)

In order to run the example code, you should just be able to paste it into a default project Xcode generates if you create a new UIKit app. Just replace the view controller they gave you with this one to see it in action.

import UIKit

struct StickyView {
    let view: UIView
    let constraint: NSLayoutConstraint
}

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
    lazy var stickyViewConstraints = [Int: StickyView]()
    
    lazy var tableView: UITableView = {
        let table = UITableView()
        table.translatesAutoresizingMaskIntoConstraints = false
        table.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        table.rowHeight = 40
        table.dataSource = self
        table.delegate = self
        return table
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        addTable()
        setupStickyViews()
    }
    
    private func addTable() {
        view.addSubview(tableView)
        tableView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
    }
    
    private func setupStickyViews() {
        let cell25 = UITableViewCell()
        cell25.translatesAutoresizingMaskIntoConstraints = false
        cell25.backgroundColor = .blue
        cell25.textLabel?.text = "25"
        view.addSubview(cell25)
        cell25.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        cell25.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
        cell25.heightAnchor.constraint(equalToConstant: tableView.rowHeight).isActive = true
        
        let bottom = cell25.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
        bottom.isActive = true
        stickyViewConstraints[25] = StickyView(view: cell25, constraint: bottom)
    }

    // MARK: - Data Source
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return section == 0 ? 50 : 0
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = "\(indexPath.row)"
        return cell
    }
    
    // MARK: - Delegate
    func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        guard let stickyView = stickyViewConstraints[indexPath.row] else { return }
        stickyView.constraint.isActive = false
        var verticalConstraint: NSLayoutConstraint
        if shouldPlaceStickyViewAtTop(stickyRow: indexPath.row) {
            verticalConstraint = stickyView.view.topAnchor.constraint(equalTo: view.topAnchor)
        } else {
            verticalConstraint = stickyView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        }
        verticalConstraint.isActive = true
        stickyViewConstraints[indexPath.row] = StickyView(view: stickyView.view, constraint: verticalConstraint)
    }
    
    private func shouldPlaceStickyViewAtTop(stickyRow: Int) -> Bool {
        let visibleRows = tableView.indexPathsForVisibleRows?.map(\.row)
        guard let min = visibleRows?.min() else { return false }
        return min > stickyRow
    }
    
    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        if let stickyView = stickyViewConstraints[indexPath.row] {
            stickyView.constraint.isActive = false
            let bottom = stickyView.view.bottomAnchor.constraint(equalTo: cell.bottomAnchor)
            bottom.isActive = true
            stickyViewConstraints[indexPath.row] = StickyView(view: stickyView.view, constraint: bottom)
        }
    }
}
Related