Due to the many screen sizes in the mobile world, staying relative to screen size is crucial. Hard-coding margins and sizes based on points can be short-sighted. For example, 20 pixel spacing between views in an iPhone 6S may not be the desired effect on an iPad Pro. Instead, a 10% space may make more sense across all screen sizes and would be forward-compatible for future screen sizes as well.
State of Percentages in Auto Layout
It seems as if percentages were an after-thought to the Storyboard arena. CSS for the web on the other hand, have percentages baked in as first-class citizens for margins, padding, fonts, etc in the form of ‘em‘ or ‘vw‘ keywords to remain relative to the screen size. Android also have wide support for percentages. These spaces were born with various screen sizes in mind.
Apple on the other hand, only started out with one iPhone and remained so for a very long time. Then Auto Layout was introduced, then Size Classes, then UIStackViews… iteration over iteration to bring percentages slowly into the game. There was still something missing though.
Spacing in Storyboard are heavily based on constants. However, there actually is a way to achieve percentage-based margins. To do this, set your constant to zero and the multiplier to a percentage to fill its superview:
However, there is a catch. Notice that I have this in a container view, so the superview is not the controller view here. This means the percentage is not based on the whole screen size, but based on the immediate superview’s size. Many times, this is not the desire effect.
Instead, we want to keep all our percentages relative to the whole screen size so there is a consistent calculation for the entire layout. This becomes especially important when building screens from a Sketch or Photoshop design. It would be ridiculous for a designer to build a design per screen size, such as iPhone 4, 5, 6, iPad 2, Pro, etc. It’s not even possible for the Android world. So the margins and spaces must be responsive or relative using percentages, not points.
One can use proportionate widths against any view to give the effect of percentage-based margins. However, there’s an important scenario that cannot be achieved with this. Imaging you want to add a 5% space between two views?
You can probably get away with using a “UIStackView“, but even this is constant-based. Also, things can get messy if elements are not in a stack form and need percentage-based spacing in more complex scenarios. What is needed here is a percentage-based spacing relative to the screen’s size.
What the Hack?!
This has been a long standing issue in the Xcode / Auto Layout world. The community has resolved it using one of two techniques:
- Programmatically creating layout constraints in code: NSLayoutConstraint(item: myView1,
attribute: .Bottom,
relatedBy: .Equal,
toItem: myView2,
attribute: .Top,
multiplier: 1,
constant: view.bounds.width * 0.05) - Creating invisible views as spacers with width/height and specifying a multiplier against the controller view size.
There must be a better way…
Extending NSLayoutConstraint
Let’s subclass NSLayoutConstraint so we can use this new class in Storyboard and specify percentage margins instead. We do this by specifying the subclass in the Identity Inspector:
Now, we can specify the margin percentage in the new @IBInspectable property:
Show Me the Code!
Our subclass will add a new Storyboard field via @IBInspectable to expose our “marginPercent” property. This will be used to specify the margin percentage relative to the screen view and will be done in the “awakeFromNib” to update the existing constant. See here for the newly created subclass:
/// Layout constraint to calculate size based on multiplier. class PercentLayoutConstraint: NSLayoutConstraint { @IBInspectable var marginPercent: CGFloat = 0 var screenSize: (width: CGFloat, height: CGFloat) { return (UIScreen.mainScreen().bounds.width, UIScreen.mainScreen().bounds.height) } override func awakeFromNib() { super.awakeFromNib() guard marginPercent > 0 else { return } NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(layoutDidChange), name: UIDeviceOrientationDidChangeNotification, object: nil) } /** Re-calculate constant based on orientation and percentage. */ func layoutDidChange() { guard marginPercent > 0 else { return } switch firstAttribute { case .Top, .TopMargin, .Bottom, .BottomMargin: constant = screenSize.height * marginPercent case .Leading, .LeadingMargin, .Trailing, .TrailingMargin: constant = screenSize.width * marginPercent default: break } } deinit { guard marginPercent > 0 else { return } NSNotificationCenter.defaultCenter().removeObserver(self) } }
Actually, in the “awakeFromNib” I’m subscribing to the “UIDeviceOrientationDidChangeNotification” event so any time the layout changes, the new constant is recreated. It will always fire the first time, so this will call the “layoutDidChange” function early on. Here is where the main logic happens.
In “layoutDidChange“, it is taking the specified “marginPercentage” value from Storyboard and multiplying it against the screen width or height. If the margin percentage is to be calculated for the sides of the view, it will calculate the percentage based on the screen width. Likewise, if the margin percentage is for the top or bottom of the view, it will calculate the percentage based on the screen height.
Finally, it will use this calculated value to stuff in the constant property for the layout constraint. Since this logic is triggered every time there is an orientation change, it will fire before the Auto Layout kicks in, which is vital it happens before. What this means is that the constant is being overwritten from what was specified in Storyboard before Auto Layout lays out the views.
That’s where the caveat is… the constants in the Storyboard are not used at runtime, but instead are overwritten with the percentage based calculation. So it does require some duplicate effort, once to actually layout the views on Storyboard based on points just so you get a sense of what the screen layout looks like, then percentages kick in at runtime.
Maybe a small price to pay compared to specifying constraints in code or invisible views. What are your thoughts… acceptable trade-off, or could there be a better way?
You reinvented CGSize as a tuple.
var screenSize: CGSize {
return UIScreen.mainScreen().bounds.size
}
I don’t know why, but in my project, your code doesn’t adjust constant of constraint at the first time.
So I add one line “layoutDidChange()” in awakeFromNIB() func to adjust in first time
Then, it works very good.
Thank you for nice code~!
Thanks for code..very useful!!
@Riaxter : thanks for this line “layoutDidChange()” …Constraints are updating now.