Ethan D'Mello
Ethan D'Mello

Reputation: 119

Issue with scrollview + stackview

Problem:

I want my footer UIStackView to hug its content when views are laid out, and take priority over the UIScrollView . Currently the header UIStackView and main body UIScrollView hug its contents, causing the footer UIStackView to expand, therefore leaving a lot of space below its contents and not looking like it's not pinned to the bottom. I would like the header(UIStackView) and footer(UIStackView) to hug its contents, and the main body(UIScrollView) to expand as needed.

Platform specs:

Context:

I have a UIViewController with the following view hierarchy

UIViewController
  -UIView
    -UIStackView(header)
    -ScrollView(scrollable main body)
      -UIView
        -UIStackView
    -UIStackView(footer)

Requirements for header and footer:

Constraints:

self.view.addSubview(self.headerStackView)
NSLayoutConstraint.activate([
    self.headerStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
    self.headerStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
    self.headerStackView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor)
])

self.view.addSubview(self.scrollView)
NSLayoutConstraint.activate([
    self.scrollView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
    self.scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
    self.scrollView.topAnchor.constraint(equalTo: self.headerStackView.bottomAnchor)
])

self.view.addSubview(self.footerStackView)
NSLayoutConstraint.activate([
    self.footerStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
    self.footerStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
    self.footerStackView.topAnchor.constraint(equalTo: self.scrollView.bottomAnchor),
    self.footerStackView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor)
])

Upvotes: 1

Views: 731

Answers (2)

Ethan D'Mello
Ethan D'Mello

Reputation: 119

It turns out thew view within the footer stackview was not constrained properly. Adding the missing constraint fixed the issue.

Upvotes: -1

DonMag
DonMag

Reputation: 77423

You can do this by constraining the Top of the footer view to the Bottom of the scroll view's content, but with .priority = .defaultLow, then constrain the Bottom of the footer view to less-than-or-equal-to the Bottom of the scroll view's frame.

Here's how it can look...

enter image description here

  • yellow is the Header Stack View
  • blue is the scroll view
  • light-gray is the scroll content
  • green is the footer view
  • Header and Scroll views are siblings -- subviews of view
  • Content and Footer views are siblings -- subviews of scrollView

A quick example:

class FooterVC: UIViewController {
    
    let scrollView = UIScrollView()
    let headerStack = UIStackView()
    let footerStack = UIStackView()
    let scrollContentLabel = UILabel()
    
    var numLines: Int = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemBackground
        
        // MARK: setup the header stack view with add/remove buttons
        let addButton = UIButton()
        addButton.setTitle("Add", for: [])
        addButton.setTitleColor(.white, for: .normal)
        addButton.setTitleColor(.lightGray, for: .highlighted)
        addButton.backgroundColor = .systemRed

        let removeButton = UIButton()
        removeButton.setTitle("Remove", for: [])
        removeButton.setTitleColor(.white, for: .normal)
        removeButton.setTitleColor(.lightGray, for: .highlighted)
        removeButton.backgroundColor = .systemRed
        
        headerStack.axis = .horizontal
        headerStack.alignment = .center
        headerStack.spacing = 20
        headerStack.backgroundColor = .systemYellow
        
        // a couple re-usable objects
        var vSpacer: UIView!

        vSpacer = UIView()
        vSpacer.widthAnchor.constraint(equalToConstant: 16.0).isActive = true
        headerStack.addArrangedSubview(vSpacer)
        
        headerStack.addArrangedSubview(addButton)
        headerStack.addArrangedSubview(removeButton)

        vSpacer = UIView()
        vSpacer.widthAnchor.constraint(equalToConstant: 16.0).isActive = true
        headerStack.addArrangedSubview(vSpacer)

        // MARK: setup the footer stack view
        footerStack.axis = .vertical
        footerStack.spacing = 8
        footerStack.backgroundColor = .systemGreen

        ["Footer Stack View", "with Two Labels"].forEach { str in
            let vLabel = UILabel()
            vLabel.text = str
            vLabel.textAlignment = .center
            vLabel.font = .systemFont(ofSize: 24.0, weight: .regular)
            vLabel.textColor = .yellow
            footerStack.addArrangedSubview(vLabel)
        }
        
        // MARK: setup scroll content
        scrollContentLabel.font = .systemFont(ofSize: 44.0, weight: .light)
        scrollContentLabel.numberOfLines = 0
        scrollContentLabel.backgroundColor = UIColor(white: 0.95, alpha: 1.0)

        // so we can see the scroll view
        scrollView.backgroundColor = .systemBlue
        
        [scrollContentLabel, footerStack].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            scrollView.addSubview(v)
        }
        [headerStack, scrollView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
        }

        let g = view.safeAreaLayoutGuide
        let cg = scrollView.contentLayoutGuide
        let fg = scrollView.frameLayoutGuide
        
        NSLayoutConstraint.activate([
            
            // header stack at top of view
            headerStack.topAnchor.constraint(equalTo: g.topAnchor),
            headerStack.leadingAnchor.constraint(equalTo: g.leadingAnchor),
            headerStack.trailingAnchor.constraint(equalTo: g.trailingAnchor),
            headerStack.heightAnchor.constraint(equalToConstant: 72.0),
            // make buttons equal widths
            addButton.widthAnchor.constraint(equalTo: removeButton.widthAnchor),
            
            // scroll view Top to header stack Bottom
            scrollView.topAnchor.constraint(equalTo: headerStack.bottomAnchor),
            // other 3 sides
            scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor),
            
            // scroll content...
            // let's inset the content label 12-points on each side
            //  to make it easier to see the framing
            scrollContentLabel.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 12.0),
            scrollContentLabel.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: -12.0),
            scrollContentLabel.widthAnchor.constraint(equalTo: fg.widthAnchor, constant: -24.0),
            // top and bottom to content layout guide
            scrollContentLabel.topAnchor.constraint(equalTo: cg.topAnchor, constant: 0.0),
            scrollContentLabel.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: 0.0),

            // footer stack view - leading/trailing to frame layout guide
            footerStack.leadingAnchor.constraint(equalTo: fg.leadingAnchor),
            footerStack.trailingAnchor.constraint(equalTo: fg.trailingAnchor),

        ])
        
        var tmpConstraint: NSLayoutConstraint!
        
        // now, we want the footer to stick to the bottom of the content,
        //  but allow auto-layout to break the constraint when needed
        
        tmpConstraint = footerStack.topAnchor.constraint(equalTo: scrollContentLabel.bottomAnchor)
        tmpConstraint.priority = .defaultLow
        tmpConstraint.isActive = true
        
        // and we want the footer to stop at the bottom of the scroll view frame
        //  default is .required, but we'll set it here for emphasis
        tmpConstraint = footerStack.bottomAnchor.constraint(lessThanOrEqualTo: fg.bottomAnchor)
        tmpConstraint.priority = .required
        tmpConstraint.isActive = true

        // actions for the buttons
        addButton.addTarget(self, action: #selector(addContent(_:)), for: .touchUpInside)
        removeButton.addTarget(self, action: #selector(removeContent(_:)), for: .touchUpInside)
        
        // add the first line to the content
        addContent(nil)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        // we need to set the bottom inset of the scroll view content
        //  so it can scroll up above the footer stack view
        let h: CGFloat = footerStack.frame.height
        scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: h, right: 0)
    }
    
    @objc func addContent(_ sender: Any?) {
        // add another line of text to the content
        numLines += 1
        scrollContentLabel.text = (1...numLines).map({"Line \($0)"}).joined(separator: "\n")

        // scroll newly added line into view
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
            let r = CGRect(x: 0, y: self.scrollView.contentSize.height - 1.0, width: 1.0, height: 1.0)
            self.scrollView.scrollRectToVisible(r, animated: true)
        })
    }
    @objc func removeContent(_ sender: Any?) {
        numLines -= 1
        numLines = max(1, numLines)
        scrollContentLabel.text = (1...numLines).map({"Line \($0)"}).joined(separator: "\n")
    }

}

and it will look like this when running:

enter image description here enter image description here

enter image description here enter image description here

As we add content (in this case, just adding more lines to a label), it will "push down" the footer view until it hits the bottom of the scroll view's frame... at which point we can scroll behind the footer view.

Upvotes: 3

Related Questions