4

I have a UITableView with a variable amount of sections. Each section has a variable amount of cells and every section has a header and a footer. My UITableView also has a tableFooterView which I want to keep on the bottom of the screen at all times, except when the table is too large to fit on the screen, then the tableFooterView should be shown below the last section. What I want to accomplish is illustrated here:

Example of what I want, scenario 1

Example of what I want, scenario 2

However, currently the tableFooterView is always located right beneath the last section, so when there are for example only two sections, it looks like this:

Example of what I currently have

I am looking for a way to keep it always at the bottom, in every possible scenario. I have been looking around and because Apple doesn't support AutoLayout for the tableFooterView, I haven't found a solution yet. Similar cases replace the tableFooterView with a sectionFooter on the last section, but I can't do that as I already have sectionFooters.

Is there anybody who can help me out or point me towards the right direction? A couple of things to consider:

  • It has to be a tableFooterView;
  • Users can add sections to the UITableView and rows to the sections, so the tableFooterView should then update its location

How I set up the tableFooterView at the moment:

class CustomView: UITableViewDelegate, UITableViewDataSource {

    var myTableFooter: UIView = {

        let myTableFooter = UIView(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
        myTableFooter.backgroundColor = .red
        myTableFooter.isUserInteractionEnabled = true
        return myTableFooter

    }()

    override init(frame: CGRect) {

        super.init(frame: frame)
        setupViews()

        MyTableView.tableFooterView = myTableFooter

    }

}

EDIT: Tried the scrollViewDidScroll method as suggested, but didn't work:

func scrollViewDidScroll(_ scrollView: UIScrollView) {

    if(scrollView == myTableView) {

        let neededHeight = myTableView.frame.height - 50 - view.safeAreaInsets.bottom
        let currentHeight = myTableView.contentSize.height - 50

        let heightDifference = neededHeight - currentHeight

        if(heightDifference > 0) {

            myTableView.tableFooterView?.transform = CGAffineTransform(translationX: 0, y: heightDifference)

        }

    }

}
PennyWise
  • 595
  • 2
  • 12
  • 37
  • Have you considered switching to UICollectionView and making a custom layout? – Jon Rose May 14 '19 at 14:30
  • That would be an option. But I already have a UICollectionView as my main view, two cells inside the UICollectionView and each of them contains a UITableView. I believe there **should** be a way to accomplish what I want with a UITableView. – PennyWise May 14 '19 at 14:32
  • First... *"Apple doesn't support AutoLayout for the `tableFooterView`"* --- yes and no. It takes an extra step or two, but you certainly can use AutoLayout in `tableFooterView` ... but, that probably won't solve your issue (e.g. you cannot constrain the footer view elsewhere). One option would be to use an "auto-sizing" tableView embedded in a scroll view, with the "footerView" a standard `UIView` also embedded in the scrollView. – DonMag May 14 '19 at 14:38
  • Add a view below tableview – RajeshKumar R May 14 '19 at 14:54
  • I would love to, @RajeshKumarR. But I have no idea how so it would follow the content of the UITableView when it gets too large to fit on the screen. Any sample code? – PennyWise May 14 '19 at 14:59

3 Answers3

2

One approach would be:

  • use an extension to define a "self-sizing non-scrolling" table view
  • embed the table view and a normal UIView for the "footer" view in a "container" view
  • embed the container view in a scroll view, with a height equal to the scroll view but with a low priority
  • constrain the footer view to the bottom of the container view, and >= to the bottom of the table view

So, the "auto-height" of the tableView + the height of the footer view determines the height of the container view, which determines the .contentSize of the scroll view. The footer view will "stick" to the bottom of the container view. When the scroll view has enough content, it will "push down" the footer view.

Example:

enter image description here

enter image description here

Here is the code to create that. Everything is done via code... no IBOutlets needed, so just create a new view controller and assign its class to PennyWiseViewController:

//
//  PennyWiseViewController.swift
//
//  Created by Don Mag on 5/14/19.
//

import UIKit

final class ContentSizedTableView: UITableView {

    override var contentSize:CGSize {
        didSet {
            invalidateIntrinsicContentSize()
        }
    }

    override var intrinsicContentSize: CGSize {
        layoutIfNeeded()
        return CGSize(width: UIView.noIntrinsicMetric, height: contentSize.height)
    }

}

class MyOneLabelCell: UITableViewCell {

    // very simple one-label tableView cell

    let theLabel: UILabel = {
        let v = UILabel()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.numberOfLines = 0
        return v
    }()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        contentView.addSubview(theLabel)

        NSLayoutConstraint.activate([
            theLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8.0),
            theLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8.0),
            theLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8.0),
            theLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8.0),
            ])

    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

}

class PennyWiseViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

    let theContainerView: UIView = {
        let v = UIView()
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()

    let theScrollView: UIScrollView = {
        let v = UIScrollView()
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()

    let theTableView: ContentSizedTableView = {
        let v = ContentSizedTableView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.isScrollEnabled = false
        return v
    }()

    let theFooterView: UILabel = {
        let v = UILabel()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.backgroundColor = .red
        v.textColor = .white
        v.text = "The Footer View"
        v.textAlignment = .center
        return v
    }()

    // start with 3 sections
    // selecting the row in the first section allows adding sections
    // selecting the row in the second section allows deleting sections
    var numSections = 3

    let reuseID = "MyOneLabelCell"

    override func viewDidLoad() {
        super.viewDidLoad()

        theTableView.dataSource = self
        theTableView.delegate = self

        theTableView.register(MyOneLabelCell.self, forCellReuseIdentifier: reuseID)

        // add the views
        view.addSubview(theScrollView)
        theScrollView.addSubview(theContainerView)
        theContainerView.addSubview(theTableView)
        theContainerView.addSubview(theFooterView)

        // this will allow the container height to be at least the height of the scroll view
        // when enough content is added to the container, it will grow
        let containerHeightConstraint = theContainerView.heightAnchor.constraint(equalTo: theScrollView.heightAnchor, multiplier: 1.0)
        containerHeightConstraint.priority = .defaultLow

        NSLayoutConstraint.activate([

            // constrain scrollView to all 4 sides (safe-area)
            theScrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            theScrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            theScrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            theScrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),

            // constrain containerView to all 4 sides of scrollView
            theContainerView.topAnchor.constraint(equalTo: theScrollView.topAnchor),
            theContainerView.bottomAnchor.constraint(equalTo: theScrollView.bottomAnchor),
            theContainerView.leadingAnchor.constraint(equalTo: theScrollView.leadingAnchor),
            theContainerView.trailingAnchor.constraint(equalTo: theScrollView.trailingAnchor),

            theContainerView.widthAnchor.constraint(equalTo: theScrollView.widthAnchor),

            // constrain tableView to top/leading/trailing of constainerView
            theTableView.topAnchor.constraint(equalTo: theContainerView.topAnchor),
            theTableView.leadingAnchor.constraint(equalTo: theContainerView.leadingAnchor),
            theTableView.trailingAnchor.constraint(equalTo: theContainerView.trailingAnchor),

            // constrain footerView >= 20 from bottom of tableView
            theFooterView.topAnchor.constraint(greaterThanOrEqualTo: theTableView.bottomAnchor, constant: 20.0),

            theFooterView.leadingAnchor.constraint(equalTo: theContainerView.leadingAnchor, constant: 0.0),
            theFooterView.trailingAnchor.constraint(equalTo: theContainerView.trailingAnchor, constant: 0.0),
            theFooterView.bottomAnchor.constraint(equalTo: theContainerView.bottomAnchor, constant: 0.0),

            theFooterView.heightAnchor.constraint(equalToConstant: 150.0),

            containerHeightConstraint,

            ])

    }


    // MARK: - Table view data source

    func numberOfSections(in tableView: UITableView) -> Int {
        return numSections
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return section < 2 ? 1 : 2
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: reuseID, for: indexPath) as! MyOneLabelCell

        switch indexPath.section {
        case 0:
            cell.theLabel.text = "Add a section"
        case 1:
            cell.theLabel.text = "Delete a section"
        default:
            cell.theLabel.text = "\(indexPath)"
        }

        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)

        switch indexPath.section {
        case 0:
            numSections += 1
            tableView.reloadData()
        case 1:
            if numSections > 2 {
                numSections -= 1
                tableView.reloadData()
            }
        default:
            print("\(indexPath) was selected")
        }

    }

    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return "Section \(section) Header"
    }

    func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
        return "Section \(section) Footer"
    }

}
DonMag
  • 69,424
  • 5
  • 50
  • 86
  • Whoa! That looks promising. I will look into this after dinner, thank you very much for the effort so far. I'll let you know how it goes. – PennyWise May 14 '19 at 15:40
  • Hi mate, I am currently implementing this in my existing project and it looks promising. I still have to make a few changes, but I feel confident this should work. However, when I insert a new row or delete one, I use an animation with a .25 second duration. Because of the invalidation of the content size, the animation doesn't look good. Any idea how I can fix that? – PennyWise May 14 '19 at 16:47
  • So I am currently playing around with this solution. So far I haven't found a way to properly animate row/section insertions or deletions. Inserts seem to be fine on the TableView itself, while deleting rows/sections make the content "jump". It looks like the contentSize is set before the animation is completed, so the animation sort of disappears. The TableFooterView also doesn't animate but just moves (falls) into place where it should "end". Any idea if this can be resolved? – PennyWise May 14 '19 at 22:18
  • @PennyWise - tableView might not be the route to go then... Since you want the table view to expand vertically to fit the number of rows, you aren't reusing cells, so you could simply use a custom `UIView` (instead of `UITableViewCell`) and manipulate the constraints -- or perhaps put them in a stack view. – DonMag May 15 '19 at 13:21
  • I definitely need a UITableView. Right now, I removed all animations upon adding or removing cells or sections (methods insertSections, deleteSections, insertRows, deleteRows) and it work as needed. The only thing I can’t figure out is how to perform the animations while updating the intrinsicContentSize. I might leave it without animations for now. Have also tried working with a heightAnchor constraint without success. – PennyWise May 15 '19 at 14:05
2

I made a demo in Github:StickTableFooterView

ScreenShot

How to keep tableFooterView always on bottom of UITableView?

  1. Creating an view as tableFooterView.
  2. Creating an view and put it into tableFooterView as a subview(Pretended tableFooterView).
  3. Setting proper layout constraints.
  4. Enjoy your tableFooterView ticking on bottom of UITableView.
  • Becuase the pretended tableFooterView is out of the bound of its superview, need to deal with the touch event. See ViewWithOutboundsButtons.m.

About demo

  • Red area refers the tableFooterView.
  • Yellow area refers the pretended tableFooterView.

-----Updated------

Setting proper layout constraints:

innerView.translatesAutoresizingMaskIntoConstraints = false;
[NSLayoutConstraint constraintWithItem:innerView attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual toItem:innerView.superview attribute:NSLayoutAttributeRight multiplier:1 constant:0].active = YES;
[NSLayoutConstraint constraintWithItem:innerView attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:innerView.superview attribute:NSLayoutAttributeLeft multiplier:1 constant:0].active = YES;
[NSLayoutConstraint constraintWithItem:innerView attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:innerView.superview attribute:NSLayoutAttributeBottom multiplier:1 constant:0].active = YES;
[NSLayoutConstraint constraintWithItem:innerView attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationGreaterThanOrEqual toItem:self.tableView.superview attribute:NSLayoutAttributeBottom multiplier:1 constant:0].active = YES;
[NSLayoutConstraint constraintWithItem:innerView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:innerViewHeight].active = YES;

The key point is set layout constraint with tableView.superview:

[NSLayoutConstraint constraintWithItem:innerView attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationGreaterThanOrEqual toItem:self.tableView.superview attribute:NSLayoutAttributeBottom multiplier:1 constant:0].active = YES;
irons163
  • 54
  • 6
0

You might be able to do this by manually translating the frame of the footer view when you scroll the table. You will need to do the following:

  1. Set the view as a tableFooterView.
  2. Respond to the scrollViewDidScroll method of UIScrollViewDelegate.
  3. Calculate the amount to offset the footer view and set that as a transform: tableView.tableFooterView?.transform = CGAffineTransform(translationX: 0, y: <some value>)
Rengers
  • 14,911
  • 1
  • 36
  • 54
  • Tried this, but couldn't get it working. The frame doesn't update but all checks have passed (did some prints to test if there was some sort of error). I'll add my code in the OP for reference. – PennyWise May 14 '19 at 15:29