A Scrolling Stackview

Some months ago I read this article by Agnes Vasarhelyi. It’s about–guess what–scrollable UIStackViews. More precisely, it’s about how to correctly set up a UIStackView within a UIScrollView, using autolayout. Not long after that, I needed extactly that: a scrolling stack view for a screen I was developing at work. I decided to create something simple, yet convenient and reusable. I didn’t want to create a fancy view controller with all manner of bells and whistles. Just a simple view, that acts as scrolling stack view. Also, I did not want to have to write something like scrollView.stackView.axis = .vertical, but rather stackView.axis = .vertical.

So, we start with a scroll view. Upon initialization we embed the stack view within it. There are a couple of initializers we must implement. Additionally, we want to be able to set the scrolling axis during initialization, so we add an initializer for that. We also add a convenience initializer to facilitate creating the stack view with an array of arranged subviews:

class ScrollingStackView: UIScrollView {
    private var stackView = UIStackView()

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit(axis: .vertical)
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit(axis: .vertical)
    }

    init(axis: UILayoutConstraintAxis = .vertical) {
        super.init(frame: CGRect.zero)
        commonInit(axis: axis)
    }

    convenience init(arrangedSubviews: [UIView], axis: UILayoutConstraintAxis = .vertical) {
        self.init(axis: axis)
        for subview in arrangedSubviews {
            stackView.addArrangedSubview(subview)
        }
    }

    private func commonInit(axis: UILayoutConstraintAxis) {
        embed(view: stackView) // See bottom of article for the embed extension to UIView. 
        self.axis = axis
    }
}

You may have raised an eyebrow at the implementation of the commonInit function. We set an axis property directly on our scroll view subclass. But “a UIScrollView doesn’t have an axis property” I hear you say. That’s right. We define a computed property on the scroll view, that manages the axis property of the stack view:

    var axis: UILayoutConstraintAxis {
        get { return stackView.axis }
        set {
            axisConstraint?.isActive = false // deactivate existing constraint, if any
            switch newValue as UILayoutConstraintAxis {
                case .vertical:
                    axisConstraint = stackView.widthAnchor.constraint(equalTo: self.widthAnchor)
                case .horizontal:
                    axisConstraint = stackView.heightAnchor.constraint(equalTo: self.heightAnchor)
            }
            axisConstraint?.isActive = true // activate new constraint

            stackView.axis = newValue
        }
    }

When querying the axis property, we simply and conveniently return the stack view’s axis property. But when setting the property, we ensure the stack view is set up correctly. If you took the time to read Agnes Vasarhelyi’s article, you’ll know we need to set either a width or a height constraint on the stack view, to make the scroll view happy. We need a property to manage that constraint, since we need to disable the old constraint when switching axes. Therefore, we added a property to ScrollingStackView:

class ScrollingStackView: UIScrollView {
    private var stackView = UIStackView()
    private var axisConstraint: NSLayoutConstraint?
	
}

When changing axis, we deactivate the existing axis constraint and create and activate the new one, and hold on to it for future reference. This is really all we need to correctly manage a stack view inside a scroll view.

But, we also want to make it convenient to work with it. We don’t want to be exposed to the fact that the stack view is encapsulated inside a scroll view (unless when there’s a need for it). That’s easily achieved by vending the stack view’s properties straight from our encapsulating view:

// MARK: - Pass-throughs to UIStackView

// MARK: Managing arranged subviews
extension ScrollingStackView {
    func addArrangedSubview(_ view: UIView) {
        stackView.addArrangedSubview(view)
    }

    var arrangedSubviews: [UIView] {
        return stackView.arrangedSubviews
    }

    func insertArrangedSubview(_ view: UIView, at stackIndex: Int) {
        stackView.insertArrangedSubview(view, at: stackIndex)
    }

    func removeArrangedSubview(_ view: UIView) {
        stackView.removeArrangedSubview(view)
    }
}

// MARK: Configuring the layout
extension ScrollingStackView {
    var alignment: UIStackViewAlignment {
        get { return stackView.alignment }
        set { stackView.alignment = newValue }
    }

    var axis: UILayoutConstraintAxis {
        get { return stackView.axis }
        set {
            axisConstraint?.isActive = false // deactivate existing constraint, if any
            switch newValue as UILayoutConstraintAxis {
                case .vertical:
                    axisConstraint = stackView.widthAnchor.constraint(equalTo: self.widthAnchor)
                case .horizontal:
                    axisConstraint = stackView.heightAnchor.constraint(equalTo: self.heightAnchor)
            }
            axisConstraint?.isActive = true // activate new constraint

            stackView.axis = newValue
        }
    }

    var isBaselineRelativeArrangement: Bool {
        get { return stackView.isBaselineRelativeArrangement }
        set { stackView.isBaselineRelativeArrangement = newValue }
    }

    var distribution: UIStackViewDistribution {
        get { return stackView.distribution }
        set { stackView.distribution = newValue }
    }

    var isLayoutMarginsRelativeArrangement: Bool {
        get { return stackView.isLayoutMarginsRelativeArrangement }
        set { stackView.isLayoutMarginsRelativeArrangement = newValue }
    }

    var spacing: CGFloat {
        get { return stackView.spacing }
        set { stackView.spacing = newValue }
    }
}

// MARK: Adding space between items
@available(iOS 11.0, *)
extension ScrollingStackView {
        func customSpacing(after arrangedSubview: UIView) -> CGFloat {
        return stackView.customSpacing(after: arrangedSubview)
    }

    func setCustomSpacing(_ spacing: CGFloat, after arrangedSubview: UIView) {
        stackView.setCustomSpacing(spacing, after: arrangedSubview)
    }

    class var spacingUseDefault: CGFloat {
        return UIStackView.spacingUseDefault
    }

    class var spacingUseSystem: CGFloat {
        return UIStackView.spacingUseSystem
    }
}

// MARK: - UIView overrides
extension ScrollingStackView {
    override var layoutMargins: UIEdgeInsets {
        get { return stackView.layoutMargins }
        set { stackView.layoutMargins = newValue }
    }
}

This passing thorugh of UIStackView’s properties is what makes our view feel like a UIStackView, even if it isn’t really. Now, all we need to do is set up a ScrollingStackview, and add a stack of subviews to it:

class ViewController: UIViewController {

let stackView = ScrollingStackView()

override func viewDidLoad() {
super.viewDidLoad()

view.embed(stackView)
setupStackView()
}

func setupStackView() {
    stackView.spacing = 10.0

    for i in 1..<30 {
        let label = UILabel()
        label.text = "Hello, this is label nº \(i)"
        stackView.addArrangedSubview(label)
    }
}

And that’s it. Our ScrollingStackView will automagically scroll as needed. If you want a horizontally scrolling stackview, initialize it like this: let stackView = ScrollingStackView(axis: .horizontal).

Full source code in this gist.

Lastly, here is the convenience function we used above, to embed a view within another view, with the subview completely filling the parent view:

extension UIView {
    func embed(_ child: UIView) {
        child.translatesAutoresizingMaskIntoConstraints = false
        addSubview(child)
        let constraints = [
            child.leftAnchor.constraint(equalTo: self.leftAnchor),
            child.rightAnchor.constraint(equalTo: self.rightAnchor),
            child.topAnchor.constraint(equalTo: self.topAnchor),
            child.bottomAnchor.constraint(equalTo: self.bottomAnchor),
            ]
        NSLayoutConstraint.activate(constraints)
    }
}
Published on 24 December, 2018