11

I'm implementing collapsable section headers in a UITableViewController.

Here's how I determine how many rows to show per section:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
{
    return self.sections[section].isCollapsed ? 0 : self.sections[section].items.count
}

There is a struct that holds the section info with a bool for 'isCollapsed'.

Here's how I'm toggling their states:

private func getSectionsNeedReload(_ section: Int) -> [Int]
{
    var sectionsToReload: [Int] = [section]

    let toggleSelectedSection = !sections[section].isCollapsed

    // Toggle collapse
    self.sections[section].isCollapsed = toggleSelectedSection

    if self.previouslyOpenSection != -1 && section != self.previouslyOpenSection
    {
        self.sections[self.previouslyOpenSection].isCollapsed = !self.sections[self.previouslyOpenSection].isCollapsed
        sectionsToReload.append(self.previouslyOpenSection)
        self.previouslyOpenSection = section
    }
    else if section == self.previouslyOpenSection
    {
        self.previouslyOpenSection = -1
    }
    else
    {
        self.previouslyOpenSection = section
    }

    return sectionsToReload
}



internal func toggleSection(_ header: CollapsibleTableViewHeader, section: Int)
{
    let sectionsNeedReload = getSectionsNeedReload(section)

    self.tableView.beginUpdates()
    self.tableView.reloadSections(IndexSet(sectionsNeedReload), with: .automatic)
    self.tableView.endUpdates()
}

Everything is working and animating nicely, however in the console when collapsing an expanded section, I get this [Assert]:

[Assert] Unable to determine new global row index for preReloadFirstVisibleRow (0)

This happens, regardless of whether it's the same opened Section, closing (collapsing), or if I'm opening another section and 'auto-closing' the previously open section.

I'm not doing anything with the data; that's persistent.

Could anyone help explain what's missing? Thanks

pkamb
  • 33,281
  • 23
  • 160
  • 191
iOSProgrammingIsFun
  • 1,418
  • 1
  • 15
  • 32
  • Is your tableview made up of a bunch of sections and not many actual rows? – Byron Coetsee Nov 07 '19 at 09:35
  • Did you ever manage to fix this? – brzz Nov 20 '19 at 08:51
  • @ByronCoetsee Yes, until a section is expanded. So when all collapsed it's just section headers. When one is expanded it's all section headers for the non-expanded sections and a section header and then cells for data. – iOSProgrammingIsFun Nov 21 '19 at 02:18
  • @PaulDoesDev I did, but not by using this mechanism. I completely rewrote it so that whilst it appears the same, it works completely differently. However I'm going to leave this here in case someone can elegantly fix this, or it helps others in some way. – iOSProgrammingIsFun Nov 21 '19 at 02:20
  • I managed to solve it by adding "phantom" rows under each collapsed section header... row height of 0. Works a treat :) – Byron Coetsee Nov 21 '19 at 08:56
  • @ByronCoetsee Ha! I considered that but it felt... 'dirty'... lol. If you post a copy of your code and demonstrate the fix, I'll mark it as the answer. I just wish there was a cleaner way. – iOSProgrammingIsFun Nov 21 '19 at 22:11
  • 1
    @iOSProgrammingIsFun haha yeah I thought it may feel like a hack and it technically is, but the amount of code and the fact that it's actually pretty clean means I can let myself sleep at night :P code posted below – Byron Coetsee Nov 22 '19 at 12:59

1 Answers1

14

In order for a tableView to know where it is while it's reloading rows etc, it tries to find an "anchor row" which it uses as a reference. This is called a preReloadFirstVisibleRow. Since this tableView might not have any visible rows at some point because of all the sections being collapsed, the tableView will get confused as it can't find an anchor. It will then reset to the top.

Solution: Add a 0 height row to every group which is collapsed. That way, even if a section is collapsed, there's a still a row present (albeit of 0px height). The tableView then always has something to hook onto as a reference. You will see this in effect by the addition of a row in numberOfRowsInSection if the rowcount is 0 and handling any further indexPath.row calls by making sure to return the phatom cell value before indexPath.row is needed if the datasource.visibleRows is 0.

It's easier to demo in code:

func numberOfSections(in tableView: UITableView) -> Int {
    return datasource.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return datasource[section].visibleRows.count == 0 ? 1 : datasource[section].visibleRows.count
}

func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
    datasource[section].section = section
    return datasource[section]
}

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    if datasource[indexPath.section].visibleRows.count == 0 { return 0 }
    return datasource[indexPath.section].visibleRows[indexPath.row].bounds.height
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    if datasource[indexPath.section].visibleRows.count == 0 { return UITableViewCell() }

    // I've left this stuff here to show the real contents of a cell - note how
    // the phantom cell was returned before this point.

    let section = datasource[indexPath.section]
    let cell = TTSContentCell(withView: section.visibleRows[indexPath.row])
    cell.accessibilityLabel = "cell_\(indexPath.section)_\(indexPath.row)"
    cell.accessibilityIdentifier = "cell_\(indexPath.section)_\(indexPath.row)"
    cell.showsReorderControl = true
    return cell
}
Byron Coetsee
  • 3,533
  • 5
  • 20
  • 31
  • 1
    Hey @Byron Coetsee, your explanation made a lot of sense but it didn't solve the assert issue for me. – OhadM Jun 24 '20 at 13:43
  • It did not solve my issue either, my cells become "un-deselectable" after selecting a cell, collapsing the section, then expanding the section – Noah Iarrobino Jul 31 '20 at 20:15