Search code examples
iosswiftuitableviewuitableviewsectionheader

UITableView Table Header pulled down when collapsing collapsible section headers


I have a collapsible header for uitableview sections based on another stack overflow post (no idea where now, as that was months ago). As it happens, the testers found a weird bug where collapsing all of the sections pulls the table header view down.

*edit the table view header is just a UI view I dropped into the storyboard, inside the tableview, above the prototype cell. No significant constraints. Just height for the cells and the header. The tableview is pinned to the safe area.

Everything looks fine until you expand one of the sections off screen, then scroll it up so the rows start to slide under the floating section header at the top. Then you tap to collapse it. It collapses, but the header view is pulled down. It looks like it happens when the sections fit on one screen, and the rows were scrolled slightly before the collapse.

Any help would be appreciated.

In my demo project (happy to share), when the four sections are collapsed, it looks like this: enter image description here

When the user expands some of the sections, scrolls so a section header is sticky at the top and the contents are scrolled under it, then collapses the sticky section header, it can look like this: enter image description here

I have a protocol for the delegate:

protocol CollapsibleHeaderViewDelegate: class {
  func toggleSection(header: CollapsibleSectionHeader, section: Int)
}

protocol SectionHeaderCollapsible {
  var isCollapsed: Bool { get }
  var rowCount: Int { get }
}

And the subclass of UITableVieHeaderFooterView:

class CollapsibleHeader: UITableViewHeaderFooterView {

  @IBOutlet var sectionHeaderLabel: UILabel!
  var collapsed = false
  weak var delegate: CollapsibleHeaderViewDelegate?
  var sectionItem: SectionHeaderCollapsible?

  static let reuseIdentifer = "CollapsibleHeader"

  func configure(headerText: String) {
    textLabel?.text = headerText
  }

  override func awakeFromNib() {
    super.awakeFromNib()
    addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapHeader)))
  }

  @objc private func didTapHeader(gestureRecognizer: UITapGestureRecognizer) {
    guard let header = gestureRecognizer.view as? CollapsibleHeader else { return }

    delegate?.toggleSection(header: self, section: header.tag)
  }
}

Then the delegate does something like. this:

struct CollapsibleSection: SectionHeaderCollapsible {
  var isCollapsed: Bool = false
  var rowCount: Int {
    get {
      return isCollapsed ? 0 : dataContents.count
    }
  }
  var dataContents: [String]
}

class ViewController: UIViewController {

  @IBOutlet var tableView: UITableView!
  @IBOutlet var headerView: UITableView!

  var sections = [CollapsibleSection(isCollapsed: false, dataContents: ["first", "second"]),
                  CollapsibleSection(isCollapsed: false, dataContents: ["red", "blue"]),
                  CollapsibleSection(isCollapsed: false, dataContents: ["seven", "five"]),
                  CollapsibleSection(isCollapsed: false, dataContents: ["Josephine", "Edward"])]

  override func viewDidLoad() {
    super.viewDidLoad()
    tableView.dataSource = self
    tableView.delegate = self
    let nib = UINib(nibName: "CollapsibleHeader", bundle: nil)
    tableView.register(nib, forHeaderFooterViewReuseIdentifier: "CollapsibleHeader")
  }
}

extension ViewController: UITableViewDataSource, UITableViewDelegate {
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return sections[section].rowCount
  }

  func numberOfSections(in tableView: UITableView) -> Int {
    return sections.count
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") else { fatalError() }
    cell.textLabel?.text = sections[indexPath.section].dataContents[indexPath.row]
    return cell
  }

  func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
    guard let header = self.tableView.dequeueReusableHeaderFooterView(withIdentifier: "CollapsibleHeader") as? CollapsibleHeader else { fatalError() }
    header.sectionHeaderLabel.text = "Section \(section + 1)"
    header.delegate = self
    header.tag = section
    return header
  }

  func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
    return 100
  }
}

extension ViewController: CollapsibleHeaderViewDelegate {
  func toggleSection(header: CollapsibleHeader, section: Int) {
    sections[section].isCollapsed = !sections[section].isCollapsed
    tableView.reloadSections([section], with: .fade)
  }
}

EDIT: Looks like my coworkers created a work around based on (or at least similar to) your answer:

if tableView.contentOffset.y < 0 { 
  var offset = tableView.contentOffset 
  offset.y = tableView.contentSize.height - tableView.bounds.height     
  tableView.setContentOffset(offset, animated: true) 
} else { 
  tableView.setContentOffset(tableView.contentOffset, animated: true) 
}

Solution

  • Faced same problem, apparently right after "reloadSections", tableView's contentOffset.y has some strange value (you can see it when print "tableView.contentOffset.y" before and after "reloadSections"). So I just set contentOffset after it uncollapse to 0 offset value:

        let offset = tableView.contentOffset.y
        // Reload section
        tableView.reloadSections(IndexSet(integer: section), with: .automatic)
    
        if !sections[section].isCollapsed {
            tableView.contentOffset.y = offset - offset
        }