Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Auto Layout issue with a UITableView section header

I am working with a UITableViewController. I have a table of items that the user can delete if he goes into edit more. When he goes into edit mode, I want to show a header that gives an option to delete all items. At the same time, it should show a label giving information about how much space is being used. I want this to automatically resize if the device goes into landscape mode. From what I can tell, I need to use autolayout to do this.

I would have loved to set up the header in a UIView designed in the Storyboard, but the Storyboard only allows view controllers, not views. I know I could have a XIB file hold it, but I would rather avoid that if I could.

To start with, I've overridden the editing property so that I can redraw the table section when in editing mode.

- (void)setEditing:(BOOL)editing animated:(BOOL)animated
{
    [super setEditing:editing animated:animated];
    NSIndexSet *set = [NSIndexSet indexSetWithIndex:0];
    [self.tableView reloadSections:set withRowAnimation:UITableViewRowAnimationAutomatic];
}

I use this code to insert the section header when appropriate:

- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section
{
    if (self.isEditing)
        return [self headerView];
    else
        return nil;
}

- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section
{
    if (self.isEditing)
        return [self headerView].frame.size.height;
    else
        return 0;
}

The magic happens in the - headerView method. It returns a UIView *, getting it from a cache if necessary. It adds the button and the label and then puts in the constraints. I've used these same constraints in the Storyboard and I haven't had any problems.

- (UIView *)headerView
{
    if (headerView)
        return headerView;

    float w = [[UIScreen mainScreen] bounds].size.width;

    UIButton *deleteAllButton = [UIButton buttonWithType:UIButtonTypeRoundedRect];
    [deleteAllButton setTitle:@"Delete All" forState:UIControlStateNormal];
    CGRect deleteAllButtonFrame = CGRectMake(8.0, 8.0, 30.0, 30);   // The autolayout should resize this.
    [deleteAllButton setFrame:deleteAllButtonFrame];
    deleteAllButton.translatesAutoresizingMaskIntoConstraints = NO;
    [deleteAllButton setContentHuggingPriority:252 forAxis:UILayoutConstraintAxisHorizontal];
    [deleteAllButton setContentCompressionResistancePriority:751 forAxis:UILayoutConstraintAxisHorizontal];

    CGRect textFrame = CGRectMake(47.0, 8.0, 30.0, 30); // The autolayout should resize this.
    UILabel *currSizeText = [[UILabel alloc] initWithFrame:textFrame];
    currSizeText.text = @"You have a lot of text here telling you that you have stuff to delete";
    currSizeText.translatesAutoresizingMaskIntoConstraints = NO;
    currSizeText.adjustsFontSizeToFitWidth = YES;

    CGRect headerViewFrame = CGRectMake(0, 0, w, 48);
    headerView = [[UIView alloc] initWithFrame:headerViewFrame];
    //headerView.autoresizingMask = UIViewAutoresizingNone;//UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
    //headerView.translatesAutoresizingMaskIntoConstraints = NO;
    [headerView addSubview:deleteAllButton];
    [headerView addSubview:currSizeText];

    NSDictionary *viewsDictionary = NSDictionaryOfVariableBindings(deleteAllButton, currSizeText);

    [headerView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"|-[deleteAllButton]-[currSizeText]-|"
                                                                       options:0
                                                                       metrics:nil
                                                                         views:viewsDictionary]];

    [headerView addConstraint:[NSLayoutConstraint constraintWithItem:deleteAllButton
                                      attribute:NSLayoutAttributeHeight
                                      relatedBy:NSLayoutRelationEqual
                                         toItem:headerView
                                      attribute:NSLayoutAttributeHeight
                                     multiplier:0.5
                                       constant:0]];
    [headerView addConstraint:[NSLayoutConstraint constraintWithItem:currSizeText
                                                           attribute:NSLayoutAttributeHeight
                                                           relatedBy:NSLayoutRelationEqual
                                                              toItem:headerView
                                                           attribute:NSLayoutAttributeHeight
                                                          multiplier:0.5
                                                            constant:0]];
    return headerView;
}

Right now, everything is working beautifully. The button keeps a constant size (because the hugging and compression resistance are higher than the label's) and the label will change its text to fit the available space. It resizes when I rotate the device. The vertical centering seems off on the label, but I am willing to overlook that for now.

However, when I first setup the section header, I get an annoying autolayout warning.

2014-02-07 11:25:19.770 ErikApp[10704:70b] Unable to simultaneously satisfy constraints.
    Probably at least one of the constraints in the following list is one you don't want. Try this: (1) look at each constraint and try to figure out which you don't expect; (2) find the code that added the unwanted constraint or constraints and fix it. (Note: If you're seeing NSAutoresizingMaskLayoutConstraints that you don't understand, refer to the documentation for the UIView property translatesAutoresizingMaskIntoConstraints) 
(
    "<NSLayoutConstraint:0xb9a4ad0 H:|-(NSSpace(20))-[UIButton:0xb99e220]   (Names: '|':UIView:0xb9a4680 )>",
    "<NSLayoutConstraint:0xb9a4bf0 H:[UIButton:0xb99e220]-(NSSpace(8))-[UILabel:0xb99f530]>",
    "<NSLayoutConstraint:0xb9a4c20 H:[UILabel:0xb99f530]-(NSSpace(20))-|   (Names: '|':UIView:0xb9a4680 )>",
    "<NSAutoresizingMaskLayoutConstraint:0xa2d1680 h=--& v=--& H:[UIView:0xb9a4680(0)]>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0xb9a4bf0 H:[UIButton:0xb99e220]-(NSSpace(8))-[UILabel:0xb99f530]>

Break on objc_exception_throw to catch this in the debugger.
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKit/UIView.h> may also be helpful.

My first thought was to change the returned UIView property translatesAutoresizingMaskIntoConstraints to NO. When I do that, I get a crash instead of a warning. Not exactly an improvement.

2014-02-07 10:49:13.041 ErikApp[10597:70b] *** Assertion failure in -[UITableView layoutSublayersOfLayer:], /SourceCache/UIKit_Sim/UIKit-2903.23/UIView.m:8540
2014-02-07 10:49:13.383 ErikApp[10597:70b] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Auto Layout still required after executing -layoutSubviews. UITableView's implementation of -layoutSubviews needs to call super.'

Does anyone have a suggestion as to what to do to get rid of the warning?

like image 796
Erik Allen Avatar asked Feb 07 '14 18:02

Erik Allen


3 Answers

It seems that when your section is reloading, the UITableView at some moment has a reference to both the old section header and the new one. And if it is the same view, some issues appear. So you must always provide a different view from the tableView:viewForHeaderInSection: method.

Sometimes it is really useful to have a single instance to be presented in a section header. For this purpose you need to create a new view each time you are asked for a section header and put your custom view inside it, configuring constraints appropriately. Here's an example:

@property (strong, nonatomic) UIView *headerContentView;

- (void)viewDidLoad {
    // Create the view, which is to be presented inside the section header
    self.headerContentView = [self loadHeaderContentView];
    // Note that we have to set the following property to NO to prevent the unsatisfiable constraints
    self.headerContentView.translatesAutoresizingMaskIntoConstraints = NO;
}

- (UIView *)loadHeaderContentView {
    // Here you instantiate your custom view from a nib
    // or create it programmatically. Speaking in terms
    // of the OP, it should look like the following. (Note:
    // I have removed all the frame-related code as your are
    // not supposed to deal with frames directly with auto layout.
    // I have also removed the line setting translatesAutoresizingMaskIntoConstraints property
    // to NO of the headerContentView object as we do it explicitly in viewDidLoad.
    UIButton *deleteAllButton = [UIButton buttonWithType:UIButtonTypeRoundedRect];
    [deleteAllButton setTitle:@"Delete All" forState:UIControlStateNormal];
    deleteAllButton.translatesAutoresizingMaskIntoConstraints = NO;
    [deleteAllButton setContentHuggingPriority:252 forAxis:UILayoutConstraintAxisHorizontal];
    [deleteAllButton setContentCompressionResistancePriority:751 forAxis:UILayoutConstraintAxisHorizontal];

    UILabel *currSizeText = [[UILabel alloc] init];
    currSizeText.text = @"You have a lot of text here telling you that you have stuff to delete";
    currSizeText.translatesAutoresizingMaskIntoConstraints = NO;
    currSizeText.adjustsFontSizeToFitWidth = YES;

    UIView *headerContentView = [[UIView alloc] init];
    [headerContentView addSubview:deleteAllButton];
    [headerContentView addSubview:currSizeText];

    NSDictionary *viewsDictionary = NSDictionaryOfVariableBindings(deleteAllButton, currSizeText);

    // In the original post you used to have an ambigious layout
    // as the Y position of neither button nor label was set.
    // Note passing NSLayoutFormatAlignAllCenterY as an option
    [headerContentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"|-[deleteAllButton]-[currSizeText]-|"
                                                                              options:NSLayoutFormatAlignAllCenterY
                                                                              metrics:nil
                                                                                views:viewsDictionary]];
    [headerContentView addConstraint:[NSLayoutConstraint constraintWithItem:deleteAllButton
                                                                  attribute:NSLayoutAttributeCenterY
                                                                  relatedBy:NSLayoutRelationEqual
                                                                     toItem:headerContentView
                                                                  attribute:NSLayoutAttributeCenterY
                                                                 multiplier:1
                                                                   constant:0]];
    // Here setting the heights of the subviews
    [headerContentView addConstraint:[NSLayoutConstraint constraintWithItem:deleteAllButton
                                                           attribute:NSLayoutAttributeHeight
                                                           relatedBy:NSLayoutRelationEqual
                                                              toItem:headerContentView
                                                           attribute:NSLayoutAttributeHeight
                                                          multiplier:0.5
                                                            constant:0]];
    [headerContentView addConstraint:[NSLayoutConstraint constraintWithItem:currSizeText
                                                           attribute:NSLayoutAttributeHeight
                                                           relatedBy:NSLayoutRelationEqual
                                                              toItem:headerContentView
                                                           attribute:NSLayoutAttributeHeight
                                                          multiplier:0.5
                                                            constant:0]];
    return headerContentView;
}

- (UIView *)headerView {
    UIView *headerView = [[UIView alloc] init];
    [headerView addSubview:self.headerContentView];

    NSDictionary *views = @{@"headerContentView" : self.headerContentView};
    NSArray *hConstraints = [NSLayoutConstraint constraintsWithVisualFormat:@"H:|[headerContentView]|" options:0 metrics:nil views:views];
    NSArray *vConstraints = [NSLayoutConstraint constraintsWithVisualFormat:@"V:|[headerContentView]|" options:0 metrics:nil views:views];
    [headerView addConstraints:hConstraints];
    [headerView addConstraints:vConstraints];

    return headerView;
}

- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section
{
    if (self.isEditing)
        return [self headerView];
    return nil;      
}

- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section
{
    // You need to return a concrete value here
    // and not the current height of the header.
    if (self.isEditing)
        return 48;
    return 0;
}
like image 178
Danchoys Avatar answered Oct 23 '22 10:10

Danchoys


I created a GitHub repo for this post here:https://github.com/bilobatum/AnimatedTableHeaderDemo

This solution implements a table header view, i.e., self.tableView.tableHeaderView, instead of section headers for a table view with a single section.

The table header view and its subviews are colored for testing purposes. An arbitrary table header height is chosen for testing purposes.

The table header is lazily instantiated and animates into place when the table view enters editing mode. An animation hides the table header when the table view exits editing mode.

In general, you're not supposed to set frames when using Auto Layout. However, a table header is a special case in a sense. Don't use Auto Layout to size or position a table header. Instead, you must set a table header's frame (actually, you only need to set the rect's height). In turn, the system will translate the table header's frame into constraints.

However, it's okay to use Auto Layout on the table header's subviews. Some of these constraints are installed on the table header view.

@interface ViewController ()

@property (nonatomic, strong) NSArray *mockData;
@property (nonatomic, strong) UIButton *deleteAllButton;
@property (nonatomic, strong) UILabel *label;
@property (nonatomic, strong) UIView *headerView;

@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    self.title = @"Fruit";
    self.mockData = @[@"Orange", @"Apple", @"Pear", @"Banana", @"Cantalope"];

    self.navigationItem.rightBarButtonItem = self.editButtonItem;
}

- (UIButton *)deleteAllButton
{
    if (!_deleteAllButton) {
        _deleteAllButton = [[UIButton alloc] init];
        _deleteAllButton.backgroundColor = [UIColor grayColor];
        [_deleteAllButton setTitle:@"Delete All" forState:UIControlStateNormal];
        _deleteAllButton.translatesAutoresizingMaskIntoConstraints = NO;
        [_deleteAllButton addTarget:self action:@selector(handleDeleteAll) forControlEvents:UIControlEventTouchUpInside];
    }
    return _deleteAllButton;
}

- (UILabel *)label
{
    if (!_label) {
        _label = [[UILabel alloc] init];
        _label.backgroundColor = [UIColor yellowColor];
        _label.text = @"Delete all button prompt";
        _label.translatesAutoresizingMaskIntoConstraints = NO;
    }
    return _label;
}

- (UIView *)headerView
{
    if (!_headerView) {
        _headerView = [[UIView alloc] init];
        // WARNING: do not set translatesAutoresizingMaskIntoConstraints to NO
        _headerView.backgroundColor = [UIColor orangeColor];
        _headerView.clipsToBounds = YES;

        [_headerView addSubview:self.label];
        [_headerView addSubview:self.deleteAllButton];

        [_headerView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-[_deleteAllButton]-[_label]-|" options:NSLayoutFormatAlignAllCenterY metrics:0 views:NSDictionaryOfVariableBindings(_label, _deleteAllButton)]];

        [_headerView addConstraint:[NSLayoutConstraint constraintWithItem:self.deleteAllButton attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:_headerView attribute:NSLayoutAttributeCenterY multiplier:1.0f constant:0.0f]];
    }

    return _headerView;
}

- (void)setEditing:(BOOL)editing animated:(BOOL)animated
{
    [super setEditing:editing animated:animated];

    if (self.editing) {
        self.tableView.tableHeaderView = self.headerView;
        [self.tableView layoutIfNeeded];
    }

    [UIView animateWithDuration:1.0 animations:^{

        CGRect rect = self.headerView.frame;

        if (editing) {
            rect.size.height = 60.0f; // arbitrary; for testing purposes
        } else {
            rect.size.height = 0.0f;
        }

        self.headerView.frame = rect;
        self.tableView.tableHeaderView = self.headerView;

        [self.tableView layoutIfNeeded];

    } completion:^(BOOL finished) {

        if (!editing) {
            self.tableView.tableHeaderView = nil;
        }
    }];
}

- (void)handleDeleteAll
{
    NSLog(@"handle delete all");
}

#pragma mark - Table view data source

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return [self.mockData count];
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString *CellIdentifier = @"Cell";
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier forIndexPath:indexPath];

    cell.textLabel.text = self.mockData[indexPath.row];

    return cell;
}

@end
like image 27
bilobatum Avatar answered Oct 23 '22 10:10

bilobatum


Quite a time since you asked the question, but maybe the answer is jet helpfull to you (or others).

Autolayout has (automatically) added a constraint for the whole section header width (the last in the debug output constrains list). This should of course be no problem, as the width is taken into account when calculation the frames of the subviews. But sometimes there seem to be rounding errors in the calculation of the frames...

Just add a lower priority to one of the subviews width values to solve the problem:

...@"|-[deleteAllButton(30.0@999)]-[currSizeText]-|"

If the button width is not constant use ...deleteAllButton(>=30@999)...

like image 2
LaborEtArs Avatar answered Oct 23 '22 10:10

LaborEtArs