You can make a scroll view "grow until you reach the top, then scroll" by constraining its frame to its content.
Yes, that sounds a little odd, and is not what we generally think of when it comes to scroll views.
Assuming we've added a stack view to a "content" view, and added that content view to the scroll view, common constraints look like this:
// scroll view constrained to all 4 sides of the view
// with 20-points "padding"
scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: 20.0),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20.0),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20.0),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -20.0),
// content view constrained to all 4 sides of the scroll view's Content Layout Guide
// with 8-points "padding"
contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 8.0),
contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 8.0),
contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -8.0),
contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -8.0),
contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -16.0),
// stack view constrained to all 4 sides of the content view
// with 8-points "padding"
stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8.0),
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8.0),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8.0),
stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8.0),
We can change the .topAnchor
to:
scrollView.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor, constant: 20.0),
and add a height constraint -- with less-than-required priority:
// this will grow/shrink the scrollView height
// to match the contentView height (plus 16-points for the top/bottom "padding")
let svHeight = scrollView.heightAnchor.constraint(equalTo: contentView.heightAnchor, constant: 16.0)
// less-than-required priority, so we can set a max-height (or max-top)
svHeight.priority = .defaultHigh
// activate it
svHeight.isActive = true
Now, as the contentView
height changes - such as adding/removing labels to the stack view - the scroll view's height will change accordingly... until it reaches its "max-height / max-top" constraint.
Here's a quick example:
class SimpleSelfSizingScrollViewVC: UIViewController {
let scrollView = UIScrollView()
let contentView = UIView()
let stackView = UIStackView()
let maxHeightLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
var config = UIButton.Configuration.filled()
config.title = "Add"
let btnA = UIButton(configuration: config)
btnA.addAction (
UIAction { _ in
let v = UILabel()
v.textAlignment = .center
v.text = "Label \(self.stackView.arrangedSubviews.count + 1)"
v.backgroundColor = .yellow
v.heightAnchor.constraint(equalToConstant: 50.0).isActive = true
self.stackView.addArrangedSubview(v)
}, for: .touchUpInside
)
config.title = "Remove"
let btnB = UIButton(configuration: config)
btnB.addAction (
UIAction { _ in
if self.stackView.arrangedSubviews.count > 1 {
self.stackView.arrangedSubviews.last?.removeFromSuperview()
}
}, for: .touchUpInside
)
let btnStack = UIStackView(arrangedSubviews: [btnA, btnB])
btnStack.spacing = 20
btnStack.distribution = .fillEqually
[btnStack, maxHeightLabel, scrollView, contentView, stackView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
}
contentView.addSubview(stackView)
scrollView.addSubview(contentView)
view.addSubview(btnStack)
view.addSubview(maxHeightLabel)
view.addSubview(scrollView)
let g = view.safeAreaLayoutGuide
let cg = scrollView.contentLayoutGuide
let fg = scrollView.frameLayoutGuide
// this will grow/shrink the scrollView height
// to match the contentView height (plus 16-points for the top/bottom "padding")
let svHeight = scrollView.heightAnchor.constraint(equalTo: contentView.heightAnchor, constant: 16.0)
// less-than-required priority, so we can set a max-height (or max-top)
svHeight.priority = .defaultHigh
// activate it
svHeight.isActive = true
NSLayoutConstraint.activate([
// add/remove buttons near the top
btnStack.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
btnStack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
btnStack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
// this will determine the bottom and max-height / max-top of the scroll view
maxHeightLabel.topAnchor.constraint(equalTo: btnStack.bottomAnchor, constant: 40.0),
maxHeightLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
maxHeightLabel.widthAnchor.constraint(equalToConstant: 80.0),
maxHeightLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -80.0),
// scroll view TOP is greaterThanOrEqualTo the maxHeightLable TOP
scrollView.topAnchor.constraint(greaterThanOrEqualTo: maxHeightLabel.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: maxHeightLabel.trailingAnchor, constant: 20.0),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
scrollView.bottomAnchor.constraint(equalTo: maxHeightLabel.bottomAnchor, constant: 0.0),
// content view constrained to all 4 sides of the scroll view's Content Layout Guide
// with 8-points "padding"
contentView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 8.0),
contentView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 8.0),
contentView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: -8.0),
contentView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: -8.0),
contentView.widthAnchor.constraint(equalTo: fg.widthAnchor, constant: -16.0),
// stack view constrained to all 4 sides of the content view
// with 8-points "padding"
stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8.0),
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8.0),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8.0),
stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8.0),
])
maxHeightLabel.textAlignment = .center
maxHeightLabel.numberOfLines = 0
maxHeightLabel.font = .systemFont(ofSize: 14.0, weight: .regular)
maxHeightLabel.backgroundColor = .systemYellow
stackView.axis = .vertical
stackView.spacing = 20
// start with 3 labels in the scroll view
for _ in 1...3 {
let v = UILabel()
v.textAlignment = .center
v.text = "Label \(self.stackView.arrangedSubviews.count + 1)"
v.backgroundColor = .yellow
v.heightAnchor.constraint(equalToConstant: 50.0).isActive = true
stackView.addArrangedSubview(v)
}
view.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
// so we can see the framing
scrollView.backgroundColor = .systemRed
contentView.backgroundColor = .systemBlue
stackView.backgroundColor = .systemGreen
scrollView.layer.borderColor = UIColor.blue.cgColor
scrollView.layer.borderWidth = 2
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
maxHeightLabel.text = String(format: "Max\nHeight:\n\n%0.2f", maxHeightLabel.frame.height)
}
}
It starts out looking like this (we're beginning with 3 labels in the stack view):

- The yellow label on the left is what we'll use to control the scroll view's bottom and "max-height / max-top."
- The scroll view has a red background and a black border.
- The "content" view has a blue background
- the stack view has a green background
- and the stack view's arranged subviews have yellow backgrounds
Tap the Add button:

we add a label and the scroll view grows in height.
Tap again:

and we add another label and the scroll view grows in height.
After 8 or 9 labels (depending on device height):

and the scroll view stops growing and its content becomes scrollable.