Tuesday, May 3, 2016

Mimic UIStackView `fill proportionally` layout approach on iOS version prior to 9.0

Leave a Comment

The iOS 9.0 comes with UIStackView which makes it easier to layout views according to their content size. For example, to place 3 buttons in a row in accordance with their content width you can simply embed them in stack view, set axis horizontal and distribution - fill proportionally.

Stack View Solution

The question is how to achieve the same result in older iOS versions where stack view is not supported.

One solution I came up with is rough and doesn't look good. Again, You place 3 buttons in a row and pin them to nearest neighbors using constraints. After doing that you obviously will see content priority ambiguity error because auto layout system has no idea which button needs to grow / shrink before others.

Content Priority Ambiguity

Unfortunately, the titles are unknown before app's launch so you just might arbitrary pick a button. Let's say, I've decreased horizontal content hugging priority of middle button from standard 250 to 249. Now it'll grow before other two. Another problem is that left and right buttons strictly shrink to their content width without any nice looking paddings as in Stack View version.

Layout Comparsion

4 Answers

Answers 1

It seems over complicated for a such simple thing. But the multiplier value of a constraint is read-only, so you'll have to go the hard way.

I would do it like this if I had to:

  1. In IB: Create a UIView with constraints to fill horizontally the superView (for example)

  2. In IB: Add your 3 buttons, add contraints to align them horizontally.

  3. In code: programmatically create 1 NSConstraint between each UIButton and the UIView with attribute NSLayoutAttributeWidth and multiplier of 0.33.

Here you will get 3 buttons of the same width using 1/3 of the UIView width.

  1. Observe the title of your buttons (use KVO or subclass UIButton). When the title changes, calculate the size of your button content with something like :

    CGSize stringsize = [myButton.title sizeWithAttributes: @{NSFontAttributeName: [UIFont systemFontOfSize:14.0f]}];

  2. Remove all programmatically created constraints.

  3. Compare the calculated width (at step 4) of each button with the width of the UIView and determine a ratio for each button.

  4. Re-create the constraints of step 3 in the same way but replacing the 0.33 by the ratios calculated at step 6 and add them to the UI elements.

Answers 2

You may want to consider a backport of UIStackView, there are several open source projects. The benefit here is that eventually if you move to UIStackView you will have minimal code changes. I've used TZStackView and it has worked admirably.

Alternatively, a lighter weight solution would be to just replicate the logic for a proportional stack view layout.

  1. Calculate total intrinsic content width of the views in your stack
  2. Set the width of each view equal to the parent stack view multiplied by its proportion of the total intrinsic content width.

    I've attached a rough example of a horizontal proportional stack view below, you can run it in a Swift Playground.

import UIKit import XCPlayground  let view = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 480))  view.layer.borderWidth = 1 view.layer.borderColor = UIColor.grayColor().CGColor view.backgroundColor = UIColor.whiteColor()  XCPlaygroundPage.currentPage.liveView = view  class ProportionalStackView: UIView {    private var stackViewConstraints = [NSLayoutConstraint]()    var arrangedSubviews: [UIView] {     didSet {       addArrangedSubviews()       setNeedsUpdateConstraints()     }   }    init(arrangedSubviews: [UIView]) {     self.arrangedSubviews = arrangedSubviews     super.init(frame: CGRectZero)     addArrangedSubviews()   }    convenience init() {     self.init(arrangedSubviews: [])   }    required init?(coder aDecoder: NSCoder) {     fatalError("init(coder:) has not been implemented")   }    override func updateConstraints() {     removeConstraints(stackViewConstraints)     var newConstraints = [NSLayoutConstraint]()      for (n, subview) in arrangedSubviews.enumerate() {        newConstraints += buildVerticalConstraintsForSubview(subview)        if n == 0 {         newConstraints += buildLeadingConstraintsForLeadingSubview(subview)       } else {         newConstraints += buildConstraintsBetweenSubviews(arrangedSubviews[n-1], subviewB: subview)       }        if n == arrangedSubviews.count - 1 {         newConstraints += buildTrailingConstraintsForTrailingSubview(subview)       }      }      // for proportional widths, need to determine contribution of each subview to total content width     let totalIntrinsicWidth = subviews.reduce(0) { $0 + $1.intrinsicContentSize().width }      for subview in arrangedSubviews {       let percentIntrinsicWidth = subview.intrinsicContentSize().width / totalIntrinsicWidth       newConstraints.append(NSLayoutConstraint(item: subview, attribute: .Width, relatedBy: .Equal, toItem: self, attribute: .Width, multiplier: percentIntrinsicWidth, constant: 0))     }      addConstraints(newConstraints)     stackViewConstraints = newConstraints      super.updateConstraints()   }  }  // Helper methods extension ProportionalStackView {    private func addArrangedSubviews() {     for subview in arrangedSubviews {       if subview.superview != self {         subview.removeFromSuperview()         addSubview(subview)       }     }   }    private func buildVerticalConstraintsForSubview(subview: UIView) -> [NSLayoutConstraint] {     return NSLayoutConstraint.constraintsWithVisualFormat("V:|-0-[subview]-0-|", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["subview": subview])   }    private func buildLeadingConstraintsForLeadingSubview(subview: UIView) -> [NSLayoutConstraint] {     return NSLayoutConstraint.constraintsWithVisualFormat("|-0-[subview]", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["subview": subview])   }    private func buildConstraintsBetweenSubviews(subviewA: UIView, subviewB: UIView) -> [NSLayoutConstraint] {     return NSLayoutConstraint.constraintsWithVisualFormat("[subviewA]-0-[subviewB]", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["subviewA": subviewA, "subviewB": subviewB])   }    private func buildTrailingConstraintsForTrailingSubview(subview: UIView) -> [NSLayoutConstraint] {     return NSLayoutConstraint.constraintsWithVisualFormat("[subview]-0-|", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["subview": subview])   }  }  let labelA = UILabel() labelA.text = "Foo"  let labelB = UILabel() labelB.text = "FooBar"  let labelC = UILabel() labelC.text = "FooBarBaz"  let stack = ProportionalStackView(arrangedSubviews: [labelA, labelB, labelC])  stack.translatesAutoresizingMaskIntoConstraints = false labelA.translatesAutoresizingMaskIntoConstraints = false labelB.translatesAutoresizingMaskIntoConstraints = false labelC.translatesAutoresizingMaskIntoConstraints = false labelA.backgroundColor = UIColor.orangeColor() labelB.backgroundColor = UIColor.greenColor() labelC.backgroundColor = UIColor.redColor()  view.addSubview(stack) view.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("|-0-[stack]-0-|", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["stack": stack])) view.addConstraint(NSLayoutConstraint(item: stack, attribute: .Top, relatedBy: .Equal, toItem: view, attribute: .Top, multiplier: 1, constant: 0)) 

Answers 3

Use autolayout to your advantage. It can do all the heavy lifting for you.

Here is a UIViewController that lays out 3 UILabels, as you have in your screen shot, with no calculations. There are 3 UIView subviews that are used to give the labels "padding" and set the background color. Each of those UIViews has a UILabel subview that just shows the text and nothing else.

All of the layout is done with autolayout in viewDidLoad, which means no calculating ratios or frames and no KVO. Changing things like padding and compression/hugging priorities is a breeze. This also potentially avoids a dependency on an open source solution like TZStackView. This is just as easily setup in interface builder with absolutely no code needed.

class StackViewController: UIViewController {      private let leftView: UIView = {         let leftView = UIView()         leftView.translatesAutoresizingMaskIntoConstraints = false         leftView.backgroundColor = .blueColor()         return leftView     }()      private let leftLabel: UILabel = {         let leftLabel = UILabel()         leftLabel.translatesAutoresizingMaskIntoConstraints = false         leftLabel.textColor = .whiteColor()         leftLabel.text = "A medium title"         leftLabel.textAlignment = .Center         return leftLabel     }()      private let middleView: UIView = {         let middleView = UIView()         middleView.translatesAutoresizingMaskIntoConstraints = false         middleView.backgroundColor = .redColor()         return middleView     }()      private let middleLabel: UILabel = {         let middleLabel = UILabel()         middleLabel.translatesAutoresizingMaskIntoConstraints = false         middleLabel.textColor = .whiteColor()         middleLabel.text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit"         middleLabel.textAlignment = .Center         return middleLabel     }()      private let rightView: UIView = {         let rightView = UIView()         rightView.translatesAutoresizingMaskIntoConstraints = false         rightView.backgroundColor = .greenColor()         return rightView     }()      private let rightLabel: UILabel = {         let rightLabel = UILabel()         rightLabel.translatesAutoresizingMaskIntoConstraints = false         rightLabel.textColor = .whiteColor()         rightLabel.text = "OK"         rightLabel.textAlignment = .Center         return rightLabel     }()       override func viewDidLoad() {         super.viewDidLoad()          view.addSubview(leftView)         view.addSubview(middleView)         view.addSubview(rightView)          leftView.addSubview(leftLabel)         middleView.addSubview(middleLabel)         rightView.addSubview(rightLabel)          let views: [String : AnyObject] = [             "topLayoutGuide" : topLayoutGuide,             "leftView" : leftView,             "leftLabel" : leftLabel,             "middleView" : middleView,             "middleLabel" : middleLabel,             "rightView" : rightView,             "rightLabel" : rightLabel         ]          // Horizontal padding for UILabels inside their respective UIViews         NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|-(16)-[leftLabel]-(16)-|", options: [], metrics: nil, views: views))         NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|-(16)-[middleLabel]-(16)-|", options: [], metrics: nil, views: views))         NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|-(16)-[rightLabel]-(16)-|", options: [], metrics: nil, views: views))          // Vertical padding for UILabels inside their respective UIViews         NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-(6)-[leftLabel]-(6)-|", options: [], metrics: nil, views: views))         NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-(6)-[middleLabel]-(6)-|", options: [], metrics: nil, views: views))         NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-(6)-[rightLabel]-(6)-|", options: [], metrics: nil, views: views))          // Set the views' vertical position. The height can be determined from the label's intrinsic content size, so you only need to specify a y position to layout from. In this case, we specified the top of the screen.         NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:[topLayoutGuide][leftView]", options: [], metrics: nil, views: views))         NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:[topLayoutGuide][middleView]", options: [], metrics: nil, views: views))         NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:[topLayoutGuide][rightView]", options: [], metrics: nil, views: views))          // Horizontal layout of views         NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[leftView][middleView][rightView]|", options: [], metrics: nil, views: views))          // Make sure the middle view is the view that expands to fill up the extra space         middleLabel.setContentHuggingPriority(UILayoutPriorityDefaultLow, forAxis: .Horizontal)         middleView.setContentHuggingPriority(UILayoutPriorityDefaultLow, forAxis: .Horizontal)     }  } 

Resulting view:

Answers 4

Yes we can get the same results using only constraints :)

source code

Imagine, I have three labels :

  1. firstLabel with intrinsic content size equal to (62.5, 40)
  2. secondLabel with intrinsic content size equal to (170.5, 40)
  3. thirdLabel with intrinsic content size equal to (54, 40)

Strucuture

-- ParentView --     -- UIView -- (replace here the UIStackView)        -- Label 1 --         -- Label 2 --         -- Label 3 --             

Constraints

for example the UIView has this constraints : view.leading = superview.leading, view.trailing = superview.trailing, and it is centered vertically

enter image description here

UILabels constraints

enter image description here

SecondLabel.width equal to:

firstLabel.width * (secondLabelIntrinsicSizeWidth / firstLabelIntrinsicSizeWidth)

ThirdLabel.width equal to:

firstLabel.width * (thirdLabelIntrinsicSizeWidth / firstLabelIntrinsicSizeWidth)

I will back for more explanations

enter image description here

If You Enjoyed This, Take 5 Seconds To Share It

0 comments:

Post a Comment