UITableViewHeaderFooterView Not Being Cleaned Up, Abandoned Duplicate Header (not sticky) sometimes appears in the table view

I'm having a hard time tracking the cause of this/ coming up with a workaround for this potential bug in UITableView. I have a UITableView, using the default section header view (I implement -tableView:titleForHeaderInSection: and return a string).


After deleting some rows using -deleteRowsAtIndexPaths:withRowAnimation: I notice sometimes an extra header view will appear inside my UITableVIew. It has the same title as the sticky header above it, but it appears the table view lost its reference to the header view but didn't clean it up from the view hierarchy (it scrolls with the table view, never sticks to the top anymore). If I manually empty the entire data source and call reload data on the table view, the abandoned header view is still visible.


Has anyone run into this before and know of possible cause and/or workaround?

Replies

hi MM *****,


can't diagnose too much from what you said -- which rows in which section(s) were deleted and which section header(s) seemed like an "extra" -- so i would start with a question or two:


Q: does calling deleteRowsAtIndexPaths:withRowAnimation leave any section empty? and if so, were you expecting the header for that section to go away on its own?


Q: does the number of sections determined in numberOfSections() report the same number of headers that you see in the tableview?


perhaps we'll go from there (and showing some code would really help).


DMG

Hi,


Thanks for your reply.


> Q: does calling deleteRowsAtIndexPaths:withRowAnimation leave any section empty? and if so, were you expecting the header for that section to go away on its own?


Nope, when the problem occurs, there are still rows in the section, it does not become an empty section with a header. There is a header view sticking to the top, but also a duplicate header view with the same title scrolling in the table view, permanently. This abandoned header view now scrolls with the content, it no longer is sticky.


>Q: does the number of sections determined in numberOfSections() report the same number of headers that you see in the tableview?


Have to check this out.


This happens after removing a row via a swipe action. It doesn't happen every time I do it, tracking down the root cause is tricky. But code looks like this:


-(nullable UISwipeActionsConfiguration*)tableView:(nonnull UITableView*)tableView
trailingSwipeActionsConfigurationForRowAtIndexPath:(nonnull NSIndexPath*)indexPath
                
{
                                        

 UIContextualAction *deleteAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive
                                                                              title:@"Delete"
                                                                            handler:^(UIContextualAction *action,
                                                                                      UIView *sourceView,
                                                                                      void (^completionHandler)(BOOL))
    {
       [self.dataSource removeObjectAtIndexPath:indexPath];     

  [tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic];
        completionHandler(YES);
        
    }];



  UISwipeActionsConfiguration *swipeAction = [UISwipeActionsConfiguration configurationWithActions:@[deleteAction]];
    return swipeAction;

}

After the delete there are still rows in the section. Another thing maybe worth mentioning is that my table view cells have a complex layout. My cell subclass returns its height from sizeThatFits:, though I can't see why that would cause an issue with an abandoned header view.

> Q: does the number of sections determined in numberOfSections() report the same number of headers that you see in the tableview?


Just reproduced it. The table view returns 4 from numberOfSections after the issue occurs. There are 4 sections in the table view (this is the correct value, it does not include the "abandoned header view" which would make 5).

An unfortunate workaround is to completely circumvent UITableView's reuse queue for header views and manually maintain references to header views to reuse them like so:


-(UIView*)tableView:(UITableView*)tableView viewForHeaderInSection:(NSInteger)section
{
    NSString *title = [self.dataSource titleForHeaderInSection:section];
    if (title != nil)
    {
        UITableViewHeaderFooterView *headerView = [self.headerViewCache objectForKey:title];
        if (headerView == nil)
        {
            //Create new header view for the given title and cache it.
            headerView = [[UITableViewHeaderFooterView alloc]initWithReuseIdentifier:nil];
            headerView.textLabel.text = title;
            [self.headerViewCache setObject:headerView forKey:title];
        }
        else
        {
            //Reuse header view for the given title.
        }
        
        return headerView;
    }
   
      return nil;
    
}


This ensures that the exact same instance gets reused for each section and leaves no abandoned header views inside the table view. Since my header views all have unique titles I can use the title as a cache key.


Maybe if time permits I can try to reproduce the bug in a sample project. I haven't been able to reproduce the bug in a simple table view demo with regular cells. I'm wondering if variable row height and my table view's complex layout is a trigger.

hi,


i thought we might be going in a different direction on this, but now maybe not so much.


your (first) code post seems to be doing the right thing, with a handler that deletes the data from the data source and deletes the row from the tableView. it's the same thing i've done before without issue (although i've always put the table changes inside beginUpdates() and endUpdates(), but with a single row deletion, i don't think that's an issue).


(and i've also had to rediscover objective-C syntax, which is now read-only for me !)


two things:


-- since you're talking about sticky headers, i'm guessing you're using a Plain style UITableView. one question might be whether the same behaviour exists with a Grouped style?


-- caching your own headers seems like more work than is needed, although that brings up a question about how you currently use tableView:viewForHeaderInSection, assuming that you do at all.


otherwise, i'm out of ideas ⚠


regards,

DMG

Thanks again for your reply.


>-- since you're talking about sticky headers, i'm guessing you're using a Plain style UITableView. one question might be whether the same behaviour exists with a Grouped style?


I'm not sure. I really need sticky headers for this UI so I didn't try the grouped style.


>- - caching your own headers seems like more work than is needed, although that brings up a question about how you currently use tableView:viewForHeaderInSection, assuming that you do at all.


Indeed, caching my headers seems like more work than needed; this is the workaround I came up with to avoid the issue I described in the original post. I was not using tableView:viewForHeaderInSection at all, instead I was using -tableView:titleForHeaderInSection.


It looks like UITableView is removing the reference to a header view from its reuse queue but not cleaning it up from the view hierarchy. Then when the table view asks the data source for another title, (in tableView:viewForHeaderInSection:) it creates a new instance instead of re-using the header view already created, but the old header remains in the view hierarchy abandoned. This is why my workaround works, because I hold the reference to the original header view, and next time the table view asks for a header view, I give it the exact same instance. This happens periodically after removing a row via the swipe action (shown in the code posted above).


My cell layout is very complex, I suspect that may have something to do with it. Cells calculate their own height by implementing sizeThatFits: and the table view is set to use UITableViewAutomaticDimension for row height and estimated row height.


Also the table view periodically reloads cells when thumbnail images load (they are loaded lazily). Hard to say what could be triggering this though.


I'm pretty much out of ideas on this one too. Would be able to see what's going pretty quickly if Apple shared UITableView's source code 😁.

After three years this issue has resurfaced. Huh.. it's very hard to reproduce.

My best guess is there is some code path in UITableView that removes a header view after a delay, perhaps batched updates/animation etc. and if the data source is mutated this code path that cleans up the header view isn't being reached, and thus I'm left with an abandoned header view inside my table view.

I verified that the header view is in the view hierarchy but its not in UITableView's reuse queue...it's scrolling in there but isn't associated with any of the table view's existing sections.

Only workaround I can think of is to gather all UITableViewHeaderFooterView added to the the table view's hierarchy...

-(void)didAddSubview:(UIView*)subview

{
    [super didAddSubview:subview];

    if ([subview isKindOfClass:[UITableViewHeaderFooterView class]])

    {
        [self.theHeaderViewStash addObject:(UITableViewHeaderFooterView*)subview];
    }

}



-(void)willRemoveSubview:(UIView*)subview

{
    [super willRemoveSubview:subview];

    if ([subview isKindOfClass:[UITableViewHeaderFooterView class]])

    {
        [self.theHeaderViewStash removeObject:(UITableViewHeaderFooterView*)subview];
    }
}


Then at some point clean up:

-(void)cleanHeaderViewsLeftStranded
{
    if (self.theHeaderViewStash.count == 0) { return; }

     NSSet *visibleHeaderViews = //gather any existing header view returned from UITableView' -headerViewForSection: 
    
     //Remove
    NSMutableSet *localStash = [self.theHeaderViewStash mutableCopy];
    [localStash minusSet:visibleHeaderViews];
    
      for (UITableViewHeaderFooterView *aHeader in localStash)
    {
            [aHeader removeFromSuperview];
    }
}

I'd rather pound sand than have to do this...that's a lot of ugly code but I that's all I got...this bug has been lingering in UITableView for years...

not sure when I should call the cleanHeaderViewsLeftStranded perhaps in a -layoutSubview override.