Ray Yamamoto Hilton
Freelance Mobile Architect & iOS Developer in Melbourne, Australia
~

Layout Toolkit for iOS

iOS6 Introduces a the OSX constraints-based layout engine for using sets of rules to dynamically respond to frame changes. While this will make our lives a lot easier in the long run, the reality is that we will still need to support iOS5 for a while yet.

I wanted to create a simple view layout framework that could easily be ported to iOS6’s constraints-based system when the time comes. As such, the language is fairly semantic and the API is very lightweight.

The tools are implemented as a category on UIView. Class-methods are used to build layout views that can contain many subviews (e.g. Vertical, Horizontal and Pinned layouts, while instance-methods are used to wrap the current view and return the new view (e.g. Padding, Clipping, etc).

Code

You can find the code on github in the WSFoundation project.

Prettify your Layout Code

I wanted to avoid the procedural look that loadView() tends to take on:

1
2
3
4
5
6
7
8
9
10
11
UIView *myView = [[UIView alloc] initWithFrame:CGRectZero];
myView.backgroundColor = [UIColor blueColor];
myView.layer.borderWidth = 1;
myView.layer.borderColor = [UIColor greenColor].CGColor;

UIView *innerView = [[UIVIew alloc] initWithFrame:CGRectZero];
innerView.backgroundColor = [UIColor redColor];

[myView addSubview:innerView];

self.view = myView;

Rather, take advantage of blocks so that there is a visual mapping of code-nesting to the view hierarchy:

1
2
3
4
5
6
7
8
9
self.view = [UIView view:^(UIView *myView) {
  myView.backgroundColor = [UIColor blueColor];
  myView.layer.borderWidth = 1;
  myView.layer.borderColor = [UIColor greenColor].CGColor;

  [myView addSubview:[UIView view:^(UIView *innerView) {
      innerView.backgroundColor = [UIColor redColor];
  }]];
}];

I find that the second example is much easier to visually parse and understand. Furthermore, in many cases, you can avoid having to implement a custom layoutSubviews or viewWillLayoutSubviews method by rethinking your views to fit into one of the cases below.

The main entry point for this code is the UIView+WSLayout category. You can instantiate and extend the internal view classes, but the category should suffice for the following cases:

Layout Views

Vertical And Horizontal Layout

1
2
3
4
5
self.view = [UIView verticalLayoutView:^(UIView *verticalLayoutView) {
  [verticalLayoutView addSubview:titleLabel];
  [verticalLayoutView addFixedSpace:10];         // Utility method to add a fixed space
  [verticalLayoutView addSubview:bodyLabel];
}];

Stacks all subviews in the given direction from top-bottom (vertical) or left-right (horizontal). The layout engine will honour the view’s sizeThatFits: to calculate it’s optimum size and will stretch out subviews that declare their autoresizing masks as being flexible height/width. These views can be nested, such that a vertical view could contain a horizontal view for a button bar, etc. This is really useful for building content views.

Pinned Layout

1
2
3
4
5
6
7
8
9
self.view = [UIView pinnedToBoundsLayoutView:^(UIView *pinnedView){
  [pinnedView addSubview:backgroundImageView];
  [pinnedView addSubview:gradientOverlay];
  [pinnedView addSubview:[UIView horizontalLayoutView:^(UIView *horizontalView) {
      [horizontalView addSubview:leftButton];
      [horizontalView addFlexibleSpace];          // Utility method to add a stretchable space
      [horizontalView addSunview:rightButton];
  }]];
}];

All subviews inherit the bounds of the container. The container will consult all subviews for sizeThatFits: and return a CGSize that honours the largest height & width. This is useful for creating a view that has a few transparent views stacked in the z-axis, such as a background image, gradient overlay and then some text & buttons.

Wrapped Views

Generic Container

1
[view withContainer]

Returns a view that forwards sizeThatFits: and layoutSubviews to the receiver. It also sets the receiver’s frame to be the container views bounds. This is useful for situations where you have conflicting properties to apply to a view, such as using maskToBounds for cornerRadius as well as a drop shadow or transforming a view without changing the layout code). For example:

1
2
3
4
5
6
7
8
9
10
11
12
[[[view apply:^(UIView *view) {
  view.layer.cornerRadius = 3
  view.layer.borderWidth = 1;
  view.layer.borderColor = [UIColor greenColor].CGColor;
  view.layer.maskToBounds = YES;
}] withContainer] apply:^(UIView *view) {   
  view.layer.maskToBounds = NO;
  
  // Place frame outside of the bounds
  shadowView.frame = CGRectMake(0,view.frame.size.height, view.frame.size.width, 4);
  [view addSubview:shadowView];
}];

This will set the view to have a border with a corner radius. No subview can be drawn outside of the view’s bounds with maskToBounds turned on, so we use withContainer to return a wrapping view that can have additional views added to it. The

Fixed Size

1
[view withFixedSize:CGSizeMake(320,44)];

This will return a view with a fixed size of 320,44 and stretch the original view’s bounds to fit. This is useful when the original class (say a UIButton, which is not easily subclassable) does not implement sizeThatFits: as desired and you wish to wrap it in a view that will always return a given size. You can define behaviour without subclassing as follows:

1
2
3
4
[view withCalculatedSize:^CGSize(UIView *viewToSize, CGSize sizeToFit) {
  CGSize innerSize = [viewToSize sizeThatFits:sizeToFit];
  return CGSizeMake(innerSize.width*2, innerSize.height*2);       // Double the size of the inner view
}];

Padding & EdgeInsets

1
[view withEdgeInsets:UIEdgeInsetsMake(5,15,30,5)];

Returns a view that will pad the current view using the UIEdgeInsets provided. There is also a convenience method to inset the view on all sides by the same amount:

1
[view withPadding:20];

The above is synonymous to calling withEdgeInsets with 20 pixels on each side.

Centreing

1
[view withCentreing];

Returns a view that positions the current view in the centre of the bounds.

Scrolling

1
[view withScrolling];

Wraps the given view in a scroll view container that will call sizeThatFits on the subview. If the content size is less than the bounds of the scroll view, it will attempt to stretch it to be the same height. If this is a layout view, then it will stretch the flexible regions to make the most of the space.

apply:

1
2
3
[view apply:^(UIView *view) {
  view.backgroundColor = [UIColor redColor];
}];

There is also an apply: method added to the view, which simply yields the current view. This allows additional in-line configuration of a view without having to assign the view to a local variable.

Limitations and Gotchas

  • A horizontal/vertical layout container will stretch the view in the non-alignment direction
  • If marked with a flexible autoresizingMask in the alignment direction, it will be ignored when calculating minimum size, but stretched proportionally if the frame is larger.
  • The layout containers make heavy use of sizeThatFits:, if a view does not implement this as ‘calculate and return the size that best fits its subviews’, then odd behaviour can start to creep in.