skip to Main Content

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:

  • iOS 15
  • Xcode 13.1

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:

  • stay on screen all the time

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

2

Answers


  1. Chosen as BEST ANSWER

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


  2. 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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search