Our DNA is written in Swift
Jump

Proportional Layout with Swift

Octavio asks:

I have an Autolayout challenge for you: I have a Square view which contains some images, labels, etc. We need that view to be full width on all iPhone screen sizes & resolutions.

The view is built at 320 x 320 (or @1x) and it is expected to scale proportionally for each and every other resolution and screen size. Basically, the view and all its elements should scale together, in unison, as if it was an image.

Thanks, Octavio, for a great question/challenge!

My first reaction was: “this is a problem I had solved previously!” But to make it a bit more interesting this time I am doing it with auto layout and Swift. We might learn something in the process.

Ad

When building the iCatalog framework – which was long before auto layout – we had the same problem with interactive zones on catalog pages where we would not know the final resolution. If you position a view in a superview, normally you have to specify origin and size relative to the superview’s coordinate system.

The solution then was to save origin and size in percent of the superview’s coordinates. This way you could easily multiply the percentage with the final width/height of the superview to arrive at the runtime coordinates.

Proportional Layout

The basic formula for any auto layout constraint is:

[view1] [attribute1] IS [relation] TO [view2] [attribute2] MULTIPLIED BY [multiplicator] PLUS [constant].

To model the same behavior as outlined above, for each view we need 4 constraints:

  1. left: [view] [Left] IS [equal] TO [superview] [Width] x percentage PLUS 0
  2. top: [view] [Top] IS [equal] TO [superview] [Height] x percentage PLUS 0
  3. width: [view] [Width] IS [equal] TO [superview] [Width] x percentage PLUS 0
  4. height: [view] [Height] IS [equal] TO [superview] [Height] x percentage PLUS 0

Each of the four values is always a percentage of the width or height of the superview. For example if the superview is {0,0, 100,100} and the view is {10,20,30,40} then the percentages would be {10%, 20%, 30%, 40%}.

Interview builder doesn’t have the ability to let us specify positions as a multiple of another view’s attribute. You can only specify it in points. This means we have to do this setup in code.

We’ll want to retain the ability to design the view in Interface Builder, but we need some code that will calculate the percentages and add the corresponding constraints to the view at runtime.

Enumerating Subviews

We need to apply the constraints to all subviews of a given view to make them scale proportionally to this outermost view. For that we need a method that will enumerate all views.

Wouldn’t it be nice if UIView had a method to do that where we could have a block be executed for each of the subviews in the entire tree? Let’s add it.

extension UIView
{
  func enumerateSubviews(block: (view: UIView) -> ())
  {
    for view in self.subviews as! [UIView]
    {
      // ignore _UILayoutGuide
      if (!view.conformsToProtocol(UILayoutSupport))
      {
        view.enumerateSubviews(block)
        block(view: view)
      }
    }
  }
}

This short function contains a few interesting lessons on Swift:

  • The method takes a block, so the parameter type for that is (view: UIView) -> (). It passes in the current view of the enumeration and returns nothing i.e. (Void) or ().
  • In iOS 8, the subviews array is still defined as [AnyObject]. So to save ourselves some explicit unwrapping later on, we can just force it to be an array of UIViews: [UIView]
  • iOS installs the top and bottom auto layout guides as _UILayoutGuide objects. Those are also considered subclasses of UIView so we cannot use the “if let as! UIView” pattern. But normal UIViews don’t conform to the UILayoutSupport protocol, so this trick lets us skip the layout guides in our enumeration

The innermost code recurses the function for the subview’s subviews and then calls the block. This will allow us to add the size and origin constraints to each of the subviews. So let’s get to the code for those now.

Removing Prototyping Constraints

Do you remember when Interface Builder would require you to always add all constraints? Later Xcode became smarter and would happily accept any IB layout. The difference came from IB tacitly adding as many constraints as would be required to nail down all positions and sizes.

Those prototyping constraints would clash with our proportional constraints, so we need to get rid of them – and only them.

extension UIView
{
  func removePrototypingConstraints()
  {
    for constraint in self.constraints() as! [NSLayoutConstraint]
    {
      let name = NSStringFromClass(constraint.dynamicType)
 
      if (name.hasPrefix("NSIBPrototyping"))
      {
        self.removeConstraint(constraint)
      }
    }
  }
}

We are again using the as! [Type] pattern to make sure we get a typed array of NSLayoutConstraint objects so that removeConstraint won’t complain. We convert the dynamicType to a string and if this class name has a prefix of NSIBPrototyping we remove it from the view.

Creating Size Constraints in Swift

Let’s first tackle the constraints for the size. As you will see its quite straightforward. The biggest annoyance for me when creating it was that Xcode does not have the ability to colon-align parameters.

extension UIView
{
  func addProportionalSizeConstraints()
  {
    // need to disable autoresizing masks, they might interfere
    self.setTranslatesAutoresizingMaskIntoConstraints(false)
 
    // there must be a superview
    let superview = self.superview!
 
    // get dimensions
    let bounds = superview.bounds;
    let frame = self.frame
 
    // calculate percentages relative to bounds
    let percent_width = frame.size.width / bounds.width;
    let percent_height = frame.size.height / bounds.height;
 
    // constrain width as percent of superview
    let widthConstraint = NSLayoutConstraint(item: self,
                                        attribute: .Width,
                                        relatedBy: .Equal,
                                           toItem: superview,
                                        attribute: .Width,
                                       multiplier: percent_width,
                                         constant: 0);
    superview.addConstraint(widthConstraint);
 
    // constrain height as percent of superview
    let heightConstraint = NSLayoutConstraint(item: self,
                                         attribute: .Height,
                                         relatedBy: .Equal,
                                            toItem: superview,
                                         attribute: .Height,
                                        multiplier: percent_height,
                                          constant: 0);
    superview.addConstraint(heightConstraint);
  }
}

The first line explicitly unwraps the view’s superview. This can – of course – be nil, which is why Apple made it an optional value. But since it does not make sense to call this method with no superview set we can leave it as that. We actually want this to throw an exception if we ever call this method while superview is still nil. This is our replacement for NSAssert which we would have used in Objective-C.

We calculate percentages for width and height based on the superview’s size and then add two constraints which set the view’s width and height as corresponding fractions. Next we’ll take care of the view’s origin.

Constraining Origin in Swift

Autolayout refuses to constraint Left or LeftMargin to be a fraction of another view’s Width. The workaround I found was to instead use the Right value of the superview. The other challenge is that Left cannot be ZERO times Right. In that case – if the percent value is 0 – we instead pin Left to the superview’s Left. The same is true for the vertical dimension.

extension UIView
{
  func addProportionalOriginConstraints()
  {
    // need to disable autoresizing masks, they might interfere
    self.setTranslatesAutoresizingMaskIntoConstraints(false)
 
    // there must be a superview
    let superview = self.superview!
 
    // get dimensions
    let bounds = superview.bounds;
    let frame = self.frame
 
    // calculate percentages relative to bounds
    let percent_x = frame.origin.x / bounds.width;
    let percent_y = frame.origin.y / bounds.height;
 
  // constrain left as percent of superview
  if (percent_x > 0)
  {
    let leftMargin = NSLayoutConstraint(item: self,
                                   attribute: .Left,
                                   relatedBy: .Equal,
                                      toItem: superview,
                                   attribute: .Right,
                                  multiplier: percent_x,
                                    constant: 0);
    superview.addConstraint(leftMargin);
  }
  else
  {
    // since a multipler of 0 is illegal for .Right instead make .Left equal
    let leftMargin = NSLayoutConstraint(item: self,
                                   attribute: .Left,
                                   relatedBy: .Equal,
                                      toItem: superview,
                                   attribute: .Left,
                                  multiplier: 1,
                                    constant: 0);
    superview.addConstraint(leftMargin);
  }
 
  // constrain top as percent of superview
  if (percent_y > 0 )
  {
    let topMargin = NSLayoutConstraint(item: self,
                                  attribute: .Top,
                                  relatedBy: .Equal,
                                     toItem: superview,
                                  attribute: .Bottom,
                                 multiplier: percent_y,
                                   constant: 0);
    superview .addConstraint(topMargin);
  }
  else
  {
    // since a multipler of 0 is illegal for .Bottom we instead make .Top equal
    let topMargin = NSLayoutConstraint(item: self,
                  attribute: .Top,
                  relatedBy: .Equal,
                     toItem: superview,
                                  attribute: .Top,
                                 multiplier: 1,
                                   constant: 0);
    superview .addConstraint(topMargin);
  }
}

Update: In my first published sample I didn’t yet disable the translation of autoresizing masks. But later I found that apparently some views – when created by IB – have autoresizing masks set which interferes with the proportional layout.

Next, let’s tie these methods together.

A Simple Test

The sample project – which you can find in my GitHub Swift Examples repo – has a view subviews put together in interface builder. The root view has a simulated size of 600 x 600 and all subviews are laid out relative to that. The effect we want to achieve is that all subviews should size and position proportionally if we rotate the view between portrait and landscape.

Test in IB

I couldn’t find an easy way to get the root view’s size after UIViewController had loaded it from the NIB. It always comes out at the correct resolution for the device, e.g. {0, 0, 375, 667} for iPhone 6. This would destroy our reference frame, so we need to restore it before enumerating through the subviews adding our proportional constraints.

The next auto layout pass will restore the correct root view frame and then also our proportional constraints will adjust the frames of all subviews.

class ViewController: UIViewController
{
  override func viewDidLoad()
  {
    super.viewDidLoad()
 
    // restore reference frame from IB
    self.view.frame = CGRect(x: 0, y: 0, width: 600, height: 600)
 
    self.view.enumerateSubviews { (view) -> () in
      // remove prototyping constraints from superview
      view.superview!.removePrototypingConstraints()
 
      // add proportional constraints
      view.addProportionalOriginConstraints()
      view.addProportionalSizeConstraints()
    }
  }
}

Note the syntax of the block we are passing into our subview enumeration function. In Objective-C we would specify the parameter list outside of the curly braces, like ^(UIView *view). In Swift the parameters are inside the closure and so Apple needed a way to separate the parameter header from the block code. The “in” keyword serves this function.

Inside the block we first remove any left over prototyping constraints, then we add the layout constraints for origin and size. Build and run and you will see:

Proportional Layout Demo

All squares we had in interface builder now get proportionally resized based on the dimensions of the outermost view. The one rectangle at the upper left side also resizes as expected.

Next we add an extra square view in IB so that we always have a square.

Layout Squared

We want the view to be a square and always be toughing the outside of the screen. This is what they call “aspect fit” if the touching sides are the ones that are closer together, or “aspect fill” if its touching the sides which are wider apart.

The tricky part here is that you need to work with two different priority levels. If auto layout cannot fulfil a lesser-priority constraint then it will ignore it. Mike Wolemer from AtomicObject wrote the article that explained this this me.

The Required priority constraints are:

  • View’s Aspect Ration should be 1:1
  • Center X on superview
  • Center Y on superview
  • View’s Width should be <= superview’s Width
  • View’s Height <= superview’s Height

The constraints with a lesser priority are:

  • View’s Width = superview’s Width
  • View’s Height = superview’s Height

Required constraints have a priority level of 1000, for the lesser priority you can use any lower value. The only constraint on the square centering view is the one for the aspect ratio. All others are attached to the root view.

Square Constraints

To add the constraints referencing the root view you CMD+Click on the root view and then the centering view. Click on the add constraints button and choose Equal Widths and Equal Heights.

For the greater-than constraints you do the same, but change the relationship in the constraint inspector. Make sure that you got the order of First Item and Second Item correct. If Superview is the first item, then the relation needs to be Greater Then or Equal.

First Item Second Item

Finally we need an outlet in ViewController.swift so that we can access the squareView. Then we change the enumeration to be only this view’s subviews. Et voilá!

Now in square glory

Bonus: Automatic Font-Adjusting

There remains one item that we didn’t touch upon: the font size in UILabels. The presented approach only adjusts the frames of views, but not their contents. For a UIImageView this is no problem if you set the View Mode to “Scale to Fill”. But the font size of a label would not automatically adjust to changes in the label’s frame.

There is no facility in auto layout which would allow us to tie the font size together with any length. But we are free to create a UILabel subclass that does exactly that.

Our FontAdjustingLabel stores its initial size and font when it awakes from NIB. This enables it to calculate an adjusted font size whenever its bounds change.

class FontAdjustingLabel: UILabel
{
  var initialSize : CGSize!
  var initialFont : UIFont!
 
  override func awakeFromNib()
  {
    super.awakeFromNib()
 
    // store values from IB
    initialSize = self.frame.size
    initialFont = self.font
  }
 
  override func layoutSubviews()
  {
    super.layoutSubviews()
 
    // calculate new height
    let currentSize = self.bounds.size
    let factor = currentSize.height / initialSize.height
 
    // calculate point size of font
    let pointSize = initialFont.pointSize * factor;
 
    // make same font, but new size
    let fontDescriptor = initialFont.fontDescriptor()
    self.font = UIFont(descriptor: fontDescriptor, 
                             size: pointSize)
  }
}

I needed to add the ! at the end of the instance variable definitions so that the compiler won’t complain that there is no init method which initializes it. Contrary to Objective-C, Swift does not automatically nil all ivars. Adding a ! or ? makes it an optional value and those do get initialized with nil.

Note the use of a font descriptor. This preserves all font attributes except the point size which we calculated based on the view’s current height relative to the initial height.

Conclusion

By answering Octavio’s question, we learned a few things about auto layout and how to set up constraints in Swift code. We created an extension to UIView, so that we could call these methods on any subclass of UIView.

We found that you cannot set up proportional constraints in Interface Builder. And the opposite is true for square layouts: first we thought that “aspect fit” wouldn’t be possible in Interface Builder, but it actually is, using constraints of different priority levels.

Finally, adjusting the font size is not possible with auto layout. But we can subclass UILabel so that it adjusts its font automatically. This way we have the auto layout system work together with manual adjustments in code to achieve the desired effect.


Categories: Q&A

3 Comments »

  1. Hi, do u have the project sample?

  2. Hi, this is so great! It is exactly what I need for my project. I am in the middle of my very first app, and it’s turning out to be much more complicated than I thought it would be. I imagined that auto layout would do this automatically. Surprise!

    I’m a total newb so forgive the ignorance- What would the legalities be of my using this code, assuming I am allowed to at all?

    Thanks!

Trackbacks

  1. Quora

Leave a Comment

%d bloggers like this: