Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Creating an autolayout-based metrics view

I have a reusable view I will be using in UITableViewCell's and UICollectionViewCell's, and need to get its dimensions for tableView:heightForRowAtIndexPath:. Some subviews have stuff going on inside layoutSubviews so I can't call systemLayoutForContentSize:, instead my plan is to:

  1. Instantiate the metrics view.
  2. Set the size to include the desired width.
  3. Populate it with data.
  4. Update constraints / Layout subviews.
  5. Grab the height of the view or an internal "sizing" view.

The problem I'm running into is that I cannot force the view to layout without inserting it into the view and waiting for the runloop.

I've distilled a rather boring example. Here's View.xib. The subview is misaligned to highlight that the view is never getting laid out even to the baseline position:

View.xib

On the main thread I call:

UIView *view = [[UINib nibWithNibName:@"View" bundle:nil] instantiateWithOwner:nil options:nil][0];

NSLog(@"Subviews: %@", view.subviews);
view.frame = CGRectMake(0, 0, 200, 200);

[view updateConstraints];
[view layoutSubviews];
NSLog(@"Subviews: %@", view.subviews);

[self.view addSubview:view];
[view updateConstraints];
[view layoutSubviews];
NSLog(@"Subviews: %@", view.subviews);

dispatch_async(dispatch_get_main_queue(), ^{
    NSLog(@"Subviews: %@", view.subviews);
});

I get out the following view information:

1) "<UIView: 0x8bad9e0; frame = (50 50; 220 468); autoresize = W+H; layer = <CALayer: 0x8be0070>>"
2) "<UIView: 0x8bad9e0; frame = (50 50; 220 468); autoresize = W+H; layer = <CALayer: 0x8be0070>>"
3) "<UIView: 0x8bad9e0; frame = (50 50; 220 468); autoresize = W+H; layer = <CALayer: 0x8be0070>>"
4) "<UIView: 0x8bad9e0; frame = (0 100; 100 100); autoresize = W+H; layer = <CALayer: 0x8be0070>>"

1 indicates that the fresh-out-of-the-NIB view hasn't been laid out. 2 indicates that updateConstraints/layoutSubviews did nothing. 3 indicates that adding it to the view hierarchy did nothing. 4 finally indicates that adding to the view hierarchy and one pass through the main-loop laid out the view.

I would like to get to the point where I can get the view's dimensions without having to let the application handle it or perform manual calculations (string height + constraint1 + constraint2) on my own.

Update

I've observed that if I place view inside a UIWindow I get a slight improvement:

UIView *view = [[UINib nibWithNibName:@"View" bundle:nil] instantiateWithOwner:nil options:nil][0];
UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 200, 200)];
view.frame = CGRectMake(0, 0, 200, 200);
[window addSubview:view];
[view layoutSubviews];

If view.translatesAutoresizingMaskIntoConstraints == YES, the view's immediate subviews will be laid out, but none of their children.

like image 890
Brian Nickel Avatar asked Dec 20 '13 23:12

Brian Nickel


2 Answers

The Autolayout Question

In the basic case you mentioned, you can get the correct size by calling setNeedsLayout and then layoutIfNeeded on the container view.

From the UIView class reference on layoutIfNeeded:

Use this method to force the layout of subviews before drawing. Starting with the receiver, this method traverses upward through the view hierarchy as long as superviews require layout. Then it lays out the entire tree beneath that ancestor. Therefore, calling this method can potentially force the layout of your entire view hierarchy. The UIView implementation of this calls the equivalent CALayer method and so has the same behavior as CALayer.

I don't think the "entire view hierarchy" applies to your use case since the metrics view presumably wouldn't have a superview.

Sample Code

In a sample empty project, with just this code, the correct frame is determined after layoutIfNeeded is called:

#import "ViewController.h"

@interface ViewController ()

@property (nonatomic, strong) UIView *redView;

@end

@implementation ViewController

@synthesize redView;

- (void)viewDidLoad
{
    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.

    redView = [[UIView alloc] initWithFrame:CGRectMake(50, 50, 220, 468)];
    redView.backgroundColor = [UIColor redColor];
    redView.translatesAutoresizingMaskIntoConstraints = NO;
    [self.view addSubview:redView];

    NSLog(@"Red View frame: %@", NSStringFromCGRect(redView.frame));
    // outputs "Red View frame: {{50, 50}, {220, 468}}"

    [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[redView(==100)]" options:0 metrics:Nil views:NSDictionaryOfVariableBindings(redView)]];
    [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|-100-[redView(==100)]" options:0 metrics:Nil views:NSDictionaryOfVariableBindings(redView)]];

    NSLog(@"Red View frame: %@", NSStringFromCGRect(redView.frame));
    // outputs "Red View frame: {{50, 50}, {220, 468}}"

    [self.view setNeedsLayout];

    NSLog(@"Red View frame: %@", NSStringFromCGRect(redView.frame));
    // outputs "Red View frame: {{50, 50}, {220, 468}}"

    [self.view layoutIfNeeded];

    NSLog(@"Red View frame: %@", NSStringFromCGRect(redView.frame));
    // outputs "Red View frame: {{0, 100}, {100, 100}}"
}

@end

Additional Considerations

Slightly outside the scope of your question, here are some other issues you may run into, since I've worked on this exact problem in a real app:

  • Calculating this in heightForRowAtIndexPath: might be expensive, so you may want to precalculate and cache the results
  • Precalculation should be done on a background thread, but UIView layout doesn't work well unless it's done on the main thread
  • You should definitely implement estimatedHeightForRowAtIndexPath: to reduce the impact of these performance issues

Using intrinsicContentSize

In response to:

Some subviews have stuff going on inside layoutSubviews so I can't call systemLayoutForContentSize:

You can use this method if you implement intrinsicContentSize, which lets a view suggest an optimal size for itself. One implementation for this might be:

- (CGSize) intrinsicContentSize {
    [self layoutSubviews];
    return CGSizeMake(CGRectGetMaxX(self.bottomRightSubview.frame), CGRectGetMaxY(self.bottomRightSubview.frame));
}

This simple approach will only work if your layoutSubviews method doesn't refer to an already-set size (like self.bounds or self.frame). If it does, you may need to do something like:

- (CGSize) intrinsicContentSize {
    self.frame = CGRectMake(0, 0, 10000, 10000);

    while ([self viewIsWayTooLarge] == YES) {
        self.frame = CGRectInset(self.frame, 100, 100);
        [self layoutSubviews];
    }

    return CGSizeMake(CGRectGetMaxX(self.bottomRightSubview.frame), CGRectGetMaxY(self.bottomRightSubview.frame));
}

Obviously, you'd need to adjust these values to match the particular layout of each view, and you may need to tune for performance.

Finally, I'll add that due in part to the exponentially increasing cost of using auto-layout, for all but the simplest table cells, I usually wind up using manual height calculation.

like image 51
Aaron Brager Avatar answered Oct 15 '22 04:10

Aaron Brager


Presumably you're calling the demo code when the view controller first loads its view, like in viewDidLoad or another life cycle method. The nested subview's geometries won't reflect its constraints until viewDidLayoutSubviews is called. Nothing you do during the initial life cycle of a view controller will make that method arrive any faster.

Update 12/30/13: After testing Aaron Brager's sample code, I now realize that the previous paragraph is incorrect. Apparently, you can force layout in viewDidLoad by calling setNeedsLayout followed by layoutIfNeeded.

If you executed the demo code in response to a button click instead, I think you'll see the final geometries of your nested subview logged before the action method completes.

- (IBAction)buttonTapped:(id)sender
{
    UIView *view = [[UINib nibWithNibName:@"View" bundle:nil] instantiateWithOwner:nil options:nil][0];
    view.frame = CGRectMake(0, 0, 200, 200);

    [self.view addSubview:view];
    [self.view layoutIfNeeded];
    NSLog(@"Subviews: %@", view.subviews);
}

In the latter case, you can request layout on-demand because the view controller has completed its initial setup.

But during a view controller's initial setup, how are you going to get the final geometries of your re-usable subview?

After you set the content for the re-usable subview, have your view controller ask the subview for its size. In other words, implement a method on your custom view that calculates the size based on the content.

For example, if the subview's content is an attributed string, you could use a method like boundingRectWithSize:options:context: to help determine the size of your subview.

CGRect rect = [attributedString boundingRectWithSize:CGSizeMake(width, CGFLOAT_MAX) options:NSStringDrawingUsersLineFragmentOrigin context:nil];
like image 42
bilobatum Avatar answered Oct 15 '22 06:10

bilobatum