Evan Zimmerman Evan Zimmerman - 9 days ago 4
iOS Question

Using NSLayoutConstraints to align dynamic views vertically within a ScrollView using Swift

I have an application which has a view that is dynamically generated and can be different every time the view loads. The main view contains a ScrollView set to the bounds of the main view. Subviews are then added dynamically to the ScrollView, but the heights of these views will not be the same and they can change heights at any time (eg. user clicks contents of view and it changes). I want to use layout constraints to make sure each view stays aligned to the one above it with some arbitrary padding. See the image below:

needed layout

Right now all padding values are set to 10 (top, left, and right). I am setting the positions of these subviews manually using their frames but this does not work if the views change size, so I want to change this to use the NSLayoutConstraints, but I am running into some issues.

As a test I set the subview's frame like I did before, but then I added the constraint:

// newView is created and its frame is initialized
self.scrlView?.addSubview(newView)
let constr = NSLayoutConstraint(item: newView, attribute: NSLayoutAttribute.Top, relatedBy: NSLayoutRelation.Equal, toItem: self.previousView, attribute: NSLayoutAttribute.Bottom, multiplier: 1, constant: 10)
NSLayoutConstraints.activateConstraints([constr])
newView.translatesAutoResizingMaskIntoConstraints = false
self.previousView = newView


But the views are nowhere to be seen. What am I doing wrong? All that is needed is to make sure the tops of each view are aligned below the previous view and that they stay that way regardless of the view heights.

Also, since these views are all added to a scrollview, with using the layout constraints above how do I set the correct content size of the scroll view?

Answer

So you want something like this:

demo

Setting the content size of the scroll view with autolayout is explained in Technical Note TN2154: UIScrollView And Autolayout. To summarize, autolayout sets the contentSize of the scroll view based on the constraints between the scroll view and its descendant views. This means:

  • You need to create constraints between the scroll view and its descendant views so the scroll view's contentSize will be set correctly.

  • You need to create constraints between the tiles (the colored views in your picture) and the main view (which is the superview of the scroll view) to set the width of the tiles correctly. Constraints from a tile to its enclosing scroll view cannot set the size of the tile—only the contentSize of the scroll view.

For any view you create in code, if you intend to use constraints to control its size or position, you also need to set translatesAutoresizingMaskIntoConstraints = false. Otherwise the autoresizing mask will interfere with the behavior of your explicit constraints.

Here's how I made the demo. I used a UIButton subclass that, when tapped, toggles its own height between 60 and 120 points:

class HeightTogglingButton: UIButton {

    override init(frame: CGRect) {
        super.init(frame: frame)

        translatesAutoresizingMaskIntoConstraints = false
        heightConstraint = heightAnchor.constraint(equalToConstant: 60)
        heightConstraint.isActive = true
        addTarget(self, action: #selector(HeightTogglingButton.toggle(_:)), for: .touchUpInside)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    @IBAction func toggle(_ sender: Any) {
        UIView.animate(withDuration: 0.3) { 
            self.heightConstraint.constant = 180 - self.heightConstraint.constant
            self.window?.layoutIfNeeded()
        }
    }

    private(set) var heightConstraint: NSLayoutConstraint!
}

Then I laid out ten of these buttons in a scroll view. I set up constraints to control the width of each button, and the layout of each button relative to the other buttons. The top and bottom buttons are constrained to the top and bottom of the scroll view, so they control scrollView.contentSize.height. All buttons are constrained to the leading and trailing edges of the scroll view, so they all jointly control scrollView.contentSize.width.

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let scrollView = UIScrollView()
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(scrollView)
        NSLayoutConstraint.activate([
            scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            scrollView.topAnchor.constraint(equalTo: view.topAnchor),
            scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)])

        let margin: CGFloat = 10

        var priorAnchor = scrollView.topAnchor
        for i in 0 ..< 10 {
            let button = HeightTogglingButton()
            button.backgroundColor = UIColor(hue: CGFloat(i) / 10, saturation: 0.8, brightness: 0.3, alpha: 1)
            button.setTitle("Button \(i)", for: .normal)
            scrollView.addSubview(button)
            NSLayoutConstraint.activate([
                button.widthAnchor.constraint(equalTo: view.widthAnchor, constant: -2 * margin),
                button.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: margin),
                button.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -margin),
                button.topAnchor.constraint(equalTo: priorAnchor, constant: margin)])
            priorAnchor = button.bottomAnchor
        }

        scrollView.bottomAnchor.constraint(equalTo: priorAnchor, constant: margin).isActive = true
    }

}