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.