1

I would like to recalculate the height of a table view's footer based upon the table view's changing content size. When the table has zero rows the height of the footer will be at its maximum. As rows are added to the table the footer's height will be reduced until it reaches a minimum. What I am doing is using the footer to fill up the empty space that appears at the bottom of the table when there are zero or few rows. In addition to rows being added it is possible for the content size to change because the height (content) of an existing row has been changed.

Supposing that I have a view controller whose main view contains two subviews: a button and a table view. Clicking the button results in the data store being modified and the table's reloadData method being called. When/Where would I assign a new value to the table's tableFooterView.bounds.size.height?

I should also point out that I am using UITableViewAutomaticDimension. If, in the table's data source delegate method cellForRowAt, I print the cell heights I get:

Upper table cell height = 21.0
Upper table cell height = 21.0
Upper table cell height = 21.0
Upper table cell height = 21.0
Upper table cell height = 44.0

All 21 except for the last one, the new one. This must be due to the automatic dimensioning not yet having been applied.

Update:

I have tentatively arrived at the following solution (many thanks to all of the folks on this thread for the biggest part of the solution). I am tentative because the solution involves calling reloadData twice in order to deal with an issue with the contentSize. See this GitHub project for a demo of the contentSize issue.

class TableView: UITableView {

    override func reloadData() {
        execute() { super.reloadData() }
    }

    override func reloadRows(at indexPaths: [IndexPath], with animation: UITableView.RowAnimation) {
        execute() { super.reloadRows(at: indexPaths, with: animation) }
    }

    private func execute(reload: @escaping () -> Void) {
        CATransaction.begin()
        CATransaction.setCompletionBlock() {
            if self.adjustFooter() {
                reload() // Cause the contentSize to update (see GitHub project)
                self.layoutIfNeeded()
            }
        }
        reload()
        CATransaction.commit()
    }

    // Return true(false) if the footer was(was not) adjusted
    func adjustFooter() -> Bool {
        guard let currentFrame = tableFooterView?.frame else { return false }

        let newHeight = calcFooterHeight()
        let adjustmentNeeded = newHeight != currentFrame.height

        if adjustmentNeeded {
            tableFooterView?.frame = CGRect(x: currentFrame.minX, y: currentFrame.minY, width: currentFrame.width, height: newHeight)
        }

        return adjustmentNeeded
    }

    private let minFooterHeight: CGFloat = 44
    private func calcFooterHeight() -> CGFloat {
        guard let footerView = tableFooterView else { return 0 }

        let spaceTaken = contentSize.height - footerView.bounds.height
        let spaceAvailable = bounds.height - spaceTaken
        return spaceAvailable > minFooterHeight ? spaceAvailable : minFooterHeight
    }
}
Verticon
  • 2,419
  • 16
  • 34
  • Are you sure that you need to manually calculate size of footer view? A footer view will be resized automatically If you use `tableFooterView` property of `UITableView`. – AlexSmet Oct 04 '18 at 14:42
  • @matt Yes, I am the one initiating the change. But, as best as I can determine, I need to wait until the table view has been updated before I can perform any calculations - i.e. before I can know the row heights. I could queue up some future work but that feels kludgy. – Verticon Oct 04 '18 at 15:54
  • @AlexSmet Are you sure? It has not been my experience that the tableFooterView's size is updated as rows are added to the table. – Verticon Oct 04 '18 at 15:56
  • I was wrong, sorry. But I can offer some solution, please see my answer. – AlexSmet Oct 05 '18 at 08:07
  • @matt I would greatly value your feedback on my tentative solution. – Verticon Oct 08 '18 at 12:33

2 Answers2

0

UITableViewDelegate has method tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat which we can use to specifiy height of section footers. This method fires when we call reloadData() for table view or when screen orientation was changed, etc.

So you can implement this method to calculate a new height of the footer:

    override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {

    guard section == 0 else { return 0.0 } // assume there is only one section in the table

    var cellsHeight: CGFloat = 0.0

    let rows = self.tableView(tableView, numberOfRowsInSection: section)

    for row in 0..<rows
    {
        let indexPath = IndexPath(item: row, section: section)
        cellsHeight += self.tableView(tableView, heightForRowAt: indexPath)
    }

    let headerHeight: CGFloat = tableView.tableHeaderView?.frame.height ?? 0.0
    let footerHeight = view.frame.height - headerHeight - cellsHeight

    return footerHeight
}
AlexSmet
  • 2,141
  • 1
  • 13
  • 18
  • Thanks. The tableFooterView is not a section footer, right? Actually, since my table only has 1 section, I am considering abandoning the table footer and using a section footer instead. Are you certain that the row heights have been finalized (recall that I am using UITableViewAutomaticDimension) at the time that heightForFooterInSection is called? – Verticon Oct 05 '18 at 12:04
0

I arrived at the following solution. Many thanks to all of the folks on this thread for the biggest part of the solution. The TableViewController.TableView class provides the desired functionality. The remainder of the code fleshes out a complete example.

//
//  TableViewController.swift
//  Tables
//
//  Created by Robert Vaessen on 11/6/18.
//  Copyright © 2018 Robert Vaessen. All rights reserved.
//
//  Note: Add the following to AppDelegate:
//
//    func application(_ application: UIApplication,
//                    didFinishLaunchingWithOptions launchOptions: 
//                    [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
//        window = UIWindow(frame: UIScreen.main.bounds)
//        window?.makeKeyAndVisible()
//        window?.rootViewController = TableViewController()
//        return true
//    }


import UIKit

class TableViewController: UIViewController {

    class TableView : UITableView {

        override func reloadData() {
            execute() { super.reloadData() }
        }

        override func reloadRows(at indexPaths: [IndexPath], with animation: UITableView.RowAnimation) {
            execute() { super.reloadRows(at: indexPaths, with: animation) }
        }

        private func execute(reload: @escaping () -> Void) {
            CATransaction.begin()
            CATransaction.setCompletionBlock() {
                print("Reload completed")
                _ = self.adjustFooter()
            }
            print("\nReload begun")
            reload()
            CATransaction.commit()
        }

        private func adjustFooter() -> Bool {
            guard let footerView = tableFooterView else { return false }

            func calcFooterHeight() -> CGFloat {
                var heightUsed = tableHeaderView?.bounds.height ?? 0
                for cell in visibleCells { heightUsed += cell.bounds.height }
                let heightRemaining = bounds.height - heightUsed

                let minHeight: CGFloat = 44
                return heightRemaining > minHeight ? heightRemaining : minHeight
            }

            let newHeight = calcFooterHeight()
            guard newHeight != footerView.bounds.height else { return false }

            // Keep the origin where it is, i.e. tweaking just the height expands the frame about its center
            let currentFrame = footerView.frame
            footerView.frame = CGRect(x: currentFrame.origin.x, y: currentFrame.origin.y, width: currentFrame.width, height: newHeight)

            return true
        }
    }

    class FooterView : UIView {
        override func draw(_ rect: CGRect) {
            print("Drawing footer")
            super.draw(rect)
        }
    }

    private var tableView: TableView!

    private let cellReuseId = "TableCell"
    private let data: [UIColor] = [UIColor(white: 0.4, alpha: 1), UIColor(white: 0.5, alpha: 1), UIColor(white: 0.6, alpha: 1), UIColor(white: 0.7, alpha: 1)]
    private var dataRepeatCount = 1

    override func viewDidLoad() {
        super.viewDidLoad()

        func createTable(in: UIView) -> TableView {

            let tableView = TableView(frame: CGRect.zero)

            tableView.separatorStyle = .none
            tableView.translatesAutoresizingMaskIntoConstraints = false

            `in`.addSubview(tableView)

            tableView.centerXAnchor.constraint(equalTo: `in`.centerXAnchor).isActive = true
            tableView.centerYAnchor.constraint(equalTo: `in`.centerYAnchor).isActive = true
            tableView.widthAnchor.constraint(equalTo: `in`.widthAnchor, multiplier: 1).isActive = true
            tableView.heightAnchor.constraint(equalTo: `in`.heightAnchor, multiplier: 0.8).isActive = true

            tableView.dataSource = self
            tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellReuseId)

            return tableView
        }

        func addHeader(to: UITableView) {
            let header = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 50))
            to.tableHeaderView = header

            let color = UIColor.black
            let offset: CGFloat = 64

            let add = UIButton(type: .system)
            add.setTitle("Add", for: .normal)
            add.layer.borderColor = color.cgColor
            add.layer.borderWidth = 1
            add.layer.cornerRadius = 5
            add.tintColor = color
            add.contentEdgeInsets = UIEdgeInsets.init(top: 8, left: 8, bottom: 8, right: 8)
            add.addTarget(self, action: #selector(addRows), for: .touchUpInside)
            add.translatesAutoresizingMaskIntoConstraints = false
            header.addSubview(add)
            add.centerXAnchor.constraint(equalTo: to.centerXAnchor, constant: -offset).isActive = true
            add.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true

            let remove = UIButton(type: .system)
            remove.setTitle("Remove", for: .normal)
            remove.layer.borderColor = color.cgColor
            remove.layer.borderWidth = 1
            remove.layer.cornerRadius = 5
            remove.tintColor = color
            remove.contentEdgeInsets = UIEdgeInsets.init(top: 8, left: 8, bottom: 8, right: 8)
            remove.addTarget(self, action: #selector(removeRows), for: .touchUpInside)
            remove.translatesAutoresizingMaskIntoConstraints = false
            header.addSubview(remove)
            remove.centerXAnchor.constraint(equalTo: header.centerXAnchor, constant: offset).isActive = true
            remove.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
        }

        func addFooter(to: UITableView) {
            let footer = FooterView(frame: CGRect(x: 0, y: 0, width: 0, height: 50))
            footer.layer.borderWidth = 3
            footer.layer.borderColor = UIColor.red.cgColor
            //footer.contentMode = .redraw
            to.tableFooterView = footer
        }

        tableView = createTable(in: view)
        addHeader(to: tableView)
        addFooter(to: tableView)

        view.backgroundColor = .white
        tableView.backgroundColor = .black // UIColor(white: 0.2, alpha: 1)
        tableView.tableHeaderView!.backgroundColor = .cyan // UIColor(white: 0, alpha: 1)
        tableView.tableFooterView!.backgroundColor = .white // UIColor(white: 0, alpha: 1)
    }

    @objc private func addRows() {
        dataRepeatCount += 1
        tableView.reloadData()
    }

    @objc private func removeRows() {
        dataRepeatCount -= dataRepeatCount > 0 ? 1 : 0
        tableView.reloadData()
    }
}

extension TableViewController : UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        guard section == 0 else { fatalError("Unexpected section: \(section)") }
        return dataRepeatCount * data.count
    }

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

        cell.textLabel?.textAlignment = .center
        cell.backgroundColor = data[indexPath.row % data.count]
        cell.textLabel?.textColor = .white
        cell.textLabel?.text = "\(indexPath.row)"

        return cell
    }
}
Verticon
  • 2,419
  • 16
  • 34