Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

UITableViewHeaderFooterView subclass with auto layout and section reloading won't work well together

I am trying to incorporate auto layout into my UITableViewHeaderFooterView subclass. The class is pretty basic, just two labels. This is the complete subclass:

@implementation MBTableDetailStyleFooterView

static void MBTableDetailStyleFooterViewCommonSetup(MBTableDetailStyleFooterView *_self) {
    UILabel *rightLabel = [[UILabel alloc] init];
    _self.rightLabel = rightLabel;
    rightLabel.translatesAutoresizingMaskIntoConstraints = NO;
    [_self.contentView addSubview:rightLabel];

    UILabel *leftLabel = [[UILabel alloc] init];
    _self.leftLabel = leftLabel;
    leftLabel.translatesAutoresizingMaskIntoConstraints = NO;
    [_self.contentView addSubview:leftLabel];

    NSDictionary *views = NSDictionaryOfVariableBindings(rightLabel, leftLabel);

    NSArray *horizontalConstraints = [NSLayoutConstraint constraintsWithVisualFormat:@"|-10-[leftLabel]-(>=10)-[rightLabel]-10-|" options:0 metrics:nil views:views];
    [_self.contentView addConstraints:horizontalConstraints];

    // center views vertically in super view
    NSLayoutConstraint *leftCenterYConstraint = [NSLayoutConstraint constraintWithItem:leftLabel attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:_self.contentView attribute:NSLayoutAttributeCenterY multiplier:1 constant:0];
    [_self.contentView addConstraint:leftCenterYConstraint];
    NSLayoutConstraint *rightCenterYConstraint = [NSLayoutConstraint constraintWithItem:rightLabel attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:_self.contentView attribute:NSLayoutAttributeCenterY multiplier:1 constant:0];
    [_self.contentView addConstraint:rightCenterYConstraint];

    // same height for both labels
    NSLayoutConstraint *sameHeightConstraint = [NSLayoutConstraint constraintWithItem:leftLabel attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:rightLabel attribute:NSLayoutAttributeHeight multiplier:1 constant:0];
    [_self.contentView addConstraint:sameHeightConstraint];
}

+ (BOOL)requiresConstraintBasedLayout {
    return YES;
}

- (id)initWithReuseIdentifier:(NSString *)reuseIdentifier {
    self = [super initWithReuseIdentifier:reuseIdentifier];
    MBTableDetailStyleFooterViewCommonSetup(self);
    return self;
}

@end

This class is used as a footer in the first section in a tableView with 2 sections. The first section contains dynamic items. The second section has only one row, which is used to add new items to the first section.

If there are no items in the first section I hide the footerView. So when I add the first new item I have to reload the section so the footerView appears. The code that does all this looks like this:

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    [tableView deselectRowAtIndexPath:indexPath animated:YES];
    if (indexPath.section == 1) {
        BOOL sectionNeedsReload = ([self.data count] == 0); // reload section when no data (and therefor no footer) was present before the add
        [self.data addObject:[NSDate date]];
        NSIndexPath *newIndexPath = [NSIndexPath indexPathForRow:[self.data count]-1 inSection:0];
        if (sectionNeedsReload) {
            [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:0] withRowAnimation:UITableViewRowAnimationAutomatic];
        }
        else {
            [self.tableView insertRowsAtIndexPaths:@[newIndexPath] withRowAnimation:UITableViewRowAnimationAutomatic];
        }
        [self configureFooter:(MBTableDetailStyleFooterView *)[tableView footerViewForSection:0] forSection:0];
    }
}

- (void)configureFooter:(MBTableDetailStyleFooterView *)footer forSection:(NSInteger)section {
    footer.leftLabel.text = @"Total";
    footer.rightLabel.text = [NSString stringWithFormat:@"%d", [self.data count]];
}

- (UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger)section {
    MBTableDetailStyleFooterView *footer = nil;
    if (section == 0 && [self.data count]) {
        footer = [tableView dequeueReusableHeaderFooterViewWithIdentifier:@"Footer"];
        [self configureFooter:footer forSection:section];
    }
    return footer;
}

- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section {
    CGFloat height = 0;
    if (section == 0 && [self.data count]) {
        height = 20.0f;
    }
    return height;
}

Nothing really fancy. However, as soon as reloadSections:withRowAnimations: is called on my tableView it throws an exception because it is "Unable to simultaneously satisfy constraints.".

Somewhere the tableView added a translated auto resizing mask constraint to my footer.

(
    "<NSLayoutConstraint:0x718a1f0 H:[UILabel:0x7189130]-(10)-|   (Names: '|':_UITableViewHeaderFooterContentView:0x7188df0 )>",
    "<NSLayoutConstraint:0x7189e30 H:[UILabel:0x71892c0]-(>=10)-[UILabel:0x7189130]>",
    "<NSLayoutConstraint:0x718a0a0 H:|-(10)-[UILabel:0x71892c0]   (Names: '|':_UITableViewHeaderFooterContentView:0x7188df0 )>",
    "<NSAutoresizingMaskLayoutConstraint:0x7591ab0 h=--& v=--& H:[_UITableViewHeaderFooterContentView:0x7188df0(0)]>"
)

When I replace reloadSections:withRowAnimations: with a call to reloadData no autoresizing mask constraint is added and everything works fine.

The interesting thing is that the exception tells me that it tries to break the constraint <NSLayoutConstraint:0x7189e30 H:[UILabel:0x71892c0]-(>=10)-[UILabel:0x7189130]>

But when I log the constraints in subsequent calls to configureFooter:forSection: this constraint still exists, but the auto resizing mask constraint is gone

The constraints are exactly those that I have set up.

(
    "<NSLayoutConstraint:0x718a0a0 H:|-(10)-[UILabel:0x71892c0]   (Names: '|':_UITableViewHeaderFooterContentView:0x7188df0 )>",
    "<NSLayoutConstraint:0x7189e30 H:[UILabel:0x71892c0]-(>=10)-[UILabel:0x7189130]>",
    "<NSLayoutConstraint:0x718a1f0 H:[UILabel:0x7189130]-(10)-|   (Names: '|':_UITableViewHeaderFooterContentView:0x7188df0 )>",
    "<NSLayoutConstraint:0x718a3f0 UILabel:0x71892c0.centerY == _UITableViewHeaderFooterContentView:0x7188df0.centerY>",
    "<NSLayoutConstraint:0x718a430 UILabel:0x7189130.centerY == _UITableViewHeaderFooterContentView:0x7188df0.centerY>",
    "<NSLayoutConstraint:0x718a4b0 UILabel:0x71892c0.height == UILabel:0x7189130.height>"
)

Where does this auto resizing mask constraint come from? Where does it go?

Am I missing something? The first time I looked into auto layout was like a week ago, so this is totally possible.

like image 799
Matthias Bauch Avatar asked Jul 10 '13 21:07

Matthias Bauch


3 Answers

Working Solution as of iOS 9

In your UITableViewHeaderFooterView subclass place the following code.

- (void)setFrame:(CGRect)frame {
    if (frame.size.width == 0) {
        return;
    }
    
    [super setFrame:frame];
}

Explanation:

The tableview handles the layout of the header views and it does so by manually manipulating the frames (yes even with autolayout turned on).

If you inspect the width constraints that are on the header/footer views there are two, one contained on the superview (the table view) for the width, and one contained in the header/footer view itself for the width.

The constraint contained on the super view is a NSAutoresizingMaskLayoutConstraint which is the giveaway that the tableview depends on frames to manipulate the headers. Switching the translatesAutoresizingMaskIntoConstraints to NO on the header view affectively breaks its appearance which is another give away.

It appears that under some circumstances these header/footer views will have their frames change to a width of zero, for me it was when rows were inserted and the header views were reused. My guess is that somewhere in the UITableView code a preparation for an animation is made by starting the frame at zero width, even if you are not using an animation.

This solution should work well and should not impact scroll performance.

like image 155
Josh Bernfeld Avatar answered Nov 10 '22 07:11

Josh Bernfeld


I ran in to this last week.

The way that I've eliminated the warnings is to change my required constraints to have a priority of 999. This is a work around rather than a fix, but it does get around exceptions being thrown, caught and logged during layout.

Things that didn't work for me.

A suggestion is to set estimatedRowHeight . I tried to set the estimatedSectionHeaderHeight, but this didn't help. Setting an estimatedSectionFooterHeight created empty footers where I didn't want them, which was a bit odd.

I also tried setting translatesAutoresizingMaskIntoConstraints = NO; on the header footer view, and on its content view. Neither got rid of the warning and one lead to the layout breaking completely.

like image 9
Benjohn Avatar answered Nov 10 '22 07:11

Benjohn


So weird! Thanks for this @josh-bernfield, here is what I wrote for iOS 11.3:

override var frame: CGRect {
    get {
        return super.frame
    }
    set {
        if newValue.width == 0 { return }
        super.frame = newValue
    }
}
like image 4
coco Avatar answered Nov 10 '22 08:11

coco