How to make smart UIStackView spacers
Have you ever needed to create a StackView that has a different spacing between each of its arranged subviews? Well, if you have and your app development project needs to support older OS than iOS 11 (you can do it natively in iOS 11+) you probably just created an empty subviews with defined width/height and added it between other subviews. But hey, there's a catch.
What if you want to hide one of the original arranged subviews (let's say the middle one)? After hiding it, there will most likely be an undesirable spacing because there are empty spacer subviews from both of its sides. So you have to hide one of the empty spacer subviews too, which is probably something you want to avoid to keep your code clean. In this blogpost I will show you how to implement this a smarter way, so your spacer view automatically hides with your content view.
The idea is to add a spacer that is connected to the content view and hides with it. We will achieve this by creating an extension on UIView (which would be our content view) with function that creates a spacer view and returns it. Inside a function, a spacer view will observe content view's “hidden” property and will react on any changes of this property. The function will take parameters to distinguish size, axis and priority of the spacer.
Here is the outline of the function we are gonna implement:
extension UIView {
func createSpacer(_ size: CGFloat, axis: UILayoutConstraintAxis, priority: Int) -> UIView {
// here will be a code that creates and returns a spacer, which will observe “hidden” property of self (which is our content view)
}
}
And you will create such a spacer from your ViewController similar to this:
let aLabel = UILabel()
yourStackView.addArrangedSubview(aLabel)
yourStackView.addArrangedSubiew(aLabel.createSpacer(10, axis: .vertical, priority: 1000))
UIStackView spacers: Implementation
I am going to show you a working implementation that depends on ReactiveSwift. Then I will describe other approaches I tried and why they didn't work. The implementation also depends on SnapKit (which is autolayout framework) and of course on UIKit. So don't forget to import those at the top of the file.
import UIKit
import SnapKit
import ReactiveSwift
The implementation is pretty straightforward I think. Inside a “createSpacer” function you create a spacer, which is UIView. Then you set its “isHidden” property to be initially matching with content view's (which is self) “isHidden” property. Then you reactively bind changes of content view's “isHidden” property to spacer's “isHidden” property, so every time you hide or unhide content view. The spacer will automatically hide or unhide too. Note that the signal:forKeyPath function has to take “hidden” string as a parameter due to Objective-C legacy name. After that you only create a desired height or width (depending on the axis) size constraint of the spacer using SnapKit framework and return the spacer.
extension UIView {
private func createSpacer(_ size: CGFloat, axis: UILayoutConstraintAxis, priority: Int) -> UIView {
let spacer = UIView()
spacer.isHidden = self.isHidden
spacer.reactive.isHidden <~ self.reactive.signal(forKeyPath: "hidden").filterMap { $0 as? Bool }
spacer.snp.makeConstraints { make in
switch axis {
case .vertical: make.height.equalTo(size).priority(priority)
case .horizontal: make.width.equalTo(size).priority(priority)
}
}
return spacer
}
}
Let's try it without ReactiveSwift
So why to import ReactiveSwift when you can undoubtedly do it natively with the all new block based KVO introduced in Swift 4? Well, let's try! The new KVO is really easy to use and you don't have to worry about adding and removing the observers yourself, because your object will hold a strong reference to the observation, which gets deallocated along with your object. In our case we will have to create a subclass of UIView for our spacer view, so it can hold the reference to the observation. Unfortunately we cannot add stored property to an extension.
Delete reactive binding from the previous example and change spacer view to be an instance of a new UIView subclass called SpacerView like this:
let spacer = SpacerView(contentView: self)
Now, create a SpacerView class that will take content view as a parameter in initializer. We will observe its “hidden” property with the new block based KVO and assign it to the “observation” variable.
fileprivate class SpacerView: UIView {
var observation: NSKeyValueObservation?
init(contentView: UIView) {
super.init(frame: .zero)
observation = contentView.observe(\.hidden, options: [.new]) { (_, change) in
if let change = change.newValue {
self.isHidden = change
}
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
It works like a charm… if you are on iOS 11. Unfortunately app crashes on iOS 10 when you try to remove your content view from the stack view, because iOS 10 is not able to remove (unregister) observers from the object. I got the same result when I tried to use the old KVO. I even tried to create associated object in UIView extension that holds a reference to both content view and spacer view and in its deinit I would unregister observers. It also didn't work because content view deinits before its associated view deinits, thus making it impossible to remove observers.
UIStackView spacers: Conclusion
You probably ask how ReactiveSwift does it. So it works on all iOS versions. It uses method swizzling to get into UIView's deinit, where it unregisters observers. You can try to implement it by yourself but it is very high-level programming and can lead to a lot of undefined behavior. This all means that if we want to use smart spacers without ReactiveSwift and without method swizzling, our project has to be iOS 11+. But as I said earlier, starting in iOS 11 it is possible to do it natively with setCustomSpacing function on StackView. So there's no need to implement these smart spacers. But if you need your project to support older iOS, importing ReactiveSwift and writing this simple extension can make your stack views way smarter.