-1

The problem

I want to create something like the UISheetPresentationController with detents, but that fits its content and grows in height as content is added, up to the height of the screen. Once it has grown to the height of the screen, I want it to then stop growing and scroll. I want to achieve all of this with autolayout.

So you'd have:

- ContainerView that takes the width and height of the screen
  | - DraggableModalView
    | - UIScrollView
      | - ContentView
        | - List content

Example 1: I have just 1 list item that has a height of 50.0 pixels, then the Draggable modal view will also only be 50.0 pixels in height.

Example 2: I have 100 list items, each with a height of 50.0 pixels. The Draggable modal view will be the height of the screen, showing only the first handful of list items, and the content can be scrolled.

I should also be able to drag the modal away.

What I've tried so far:

ContainerView constraints

  • Pin bottom, leading, trailing, top to view controller

DraggableModalView constraints:

  • Pin bottom, leading, trailing top ContainerView
  • Pin >= topAnchor of container to stop it growing

UIScrollView constraints

  • Pin bottom, leading, trailing, top to DraggableModalView

ContentView constraints

  • Pin bottom, leading, trailing, top to UIScrollView
  • Pin width to equal DraggableModalView
  • Pin height to DraggableModalView (this is what makes the UIScrollView grow) but set a low priority so this breaks when it hits the top.

List items

  • Added to a stack view pinned to the ContentView
Matt Beaney
  • 460
  • 4
  • 15
  • This will depend a bit on how you plan to implement the `DraggableModalView` ... Do you have any of those tasks working yet? Are you trying to replicate the Apple Maps functionality? – DonMag Feb 23 '23 at 16:23
  • I implemented basically all of it, but it just didn't work how I wanted. All `DraggableModalView` will be is a view that can grow in size and be dragged off-screen like the Apple Maps functionality. – Matt Beaney Feb 28 '23 at 15:37
  • Are you using `UISheetPresentationController` (with custom indents)? Or are you implementing Pan Gesture in your `DraggableModalView`? Have you looked at this: https://stackoverflow.com/a/38152508/6257435 – DonMag Feb 28 '23 at 20:03
  • I ended up using `UISheetPresentationController` for now, but it isn't ideal. Custom detents are only available from iOS16 (supporting 15 currently) and tbh don't really do what I need (I'd need to actively calculate the size each time the view was updated). I checked out the link you sent, it's fine for doing the draggable modal part, I'm not too worried about that. It's the "grow until you reach the top, then scroll" part I need help with. – Matt Beaney Mar 02 '23 at 12:40

1 Answers1

0

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):

enter image description here

  • 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:

enter image description here

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

Tap again:

enter image description here

and we add another label and the scroll view grows in height.

After 8 or 9 labels (depending on device height):

enter image description here

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

DonMag
  • 69,424
  • 5
  • 50
  • 86