2

I have a stackView inside a scrollView and I would like to pin the stackView such that it starts from the bottom and grows going up as more views are added to it.

Image showing current behaviour showing the stackView pinned to the top of the view and the content going from top t bottom.

Current behaviour

Image showing desired behaviour, showing stack view growing from bottom to top.

Desired behvaiour

Current code:

CustomStackView


open class CustomStackView: UIView {
    
    public var scrollView: UIScrollView = {
        
        let view = UIScrollView(frame: .zero)
        view.isScrollEnabled = true
        view.bounces = true
        view.alwaysBounceVertical = true
        view.keyboardDismissMode = .interactive
        view.layoutMargins = .zero
        view.clipsToBounds = false
        
        return view
    }()
    
    public var stackView: UIStackView = {
        
        let stackView = UIStackView(frame: .zero)
        stackView.axis = .vertical
        stackView.alignment = .fill
        stackView.distribution = .fillProportionally
        
        let gap: CGFloat = 0
        stackView.spacing = gap
        stackView.layoutMargins = UIEdgeInsets(top: gap, left: gap, bottom: gap, right: gap)
        stackView.isLayoutMarginsRelativeArrangement = true
        
        return stackView
    }()
    
    override public init(frame: CGRect) {
        
        super.init(frame: frame)

        self.layoutMargins = .zero
        
        backgroundColor = .green

        scrollView.translatesAutoresizingMaskIntoConstraints = false
        self.addSubview(scrollView)

        NSLayoutConstraint.activate([scrollView.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor),
                                     scrollView.trailingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.trailingAnchor),
                                     scrollView.topAnchor.constraint(equalTo: self.safeAreaLayoutGuide.topAnchor),
                                     scrollView.bottomAnchor.constraint(equalTo: self.safeAreaLayoutGuide.bottomAnchor)])
        
        stackView.translatesAutoresizingMaskIntoConstraints = false
        
        scrollView.addSubview(stackView)
        
        NSLayoutConstraint.activate([stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
                                     stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
                                     stackView.topAnchor.constraint(lessThanOrEqualTo: scrollView.topAnchor),
                                     stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
                                     stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor)])
    }
    
    required public init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

CellView


class CellView: UIView {
    
    private let title: UILabel = {
        let label = UILabel(frame: .zero)
        label.textColor = .black
        label.font = .systemFont(ofSize: 18, weight: .medium)
        label.translatesAutoresizingMaskIntoConstraints = false
        label.textAlignment = .left
        label.text = "Title"
        return label
    }()
    
    private let subTitle: UILabel = {
        let label = UILabel(frame: .zero)
        label.textColor = .lightGray
        label.font = .systemFont(ofSize: 14, weight: .regular)
        label.translatesAutoresizingMaskIntoConstraints = false
        label.text = "subTitle"
        label.isHidden = true
        return label
    }()
    
    private let seperatorView: UIView = {
        let view = UIView(frame: .zero)
        view.backgroundColor = .lightGray
        view.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
        view.translatesAutoresizingMaskIntoConstraints = false
        
        return view
    }()
    
    private let headerStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.alignment = .leading
        stackView.distribution = .fill
        stackView.spacing = 0
        stackView.translatesAutoresizingMaskIntoConstraints = false
        return stackView
    }()
    

    override func awakeFromNib() {
        super.awakeFromNib()
        // Initialization code
    }

    override init(frame: CGRect) {
        super.init(frame: .zero)

        setupView()
    }
    
    init(title: String) {
        
        super.init(frame: .zero)
        
        setupView()
        configureView(with: title)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
   private func setupView() {
        
    self.backgroundColor = .gray
        
        headerStackView.addArrangedSubview(title)
        headerStackView.addArrangedSubview(subTitle)
        addSubview(seperatorView)
        
        addSubview(headerStackView)
        
        setupConstraints()
    }
    
   private func setupConstraints() {
        
        self.heightAnchor.constraint(equalToConstant: 50).isActive = true
    
        headerStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16).isActive = true
        headerStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor).isActive = true
        headerStackView.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
        
        seperatorView.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true
        seperatorView.leadingAnchor.constraint(equalTo: self.leadingAnchor).isActive = true
        seperatorView.trailingAnchor.constraint(equalTo: self.trailingAnchor).isActive = true
    }
    
    private func configureView(with title: String) {
        self.title.text = title
    }

}

ViewController:

class TestViewController: UIViewController {
    
    private let containerView: UIView = {
        let view = UIView(frame: .zero)
        view.translatesAutoresizingMaskIntoConstraints = false
        view.backgroundColor = UIColor.red
        view.clipsToBounds = true
        return view
    }()
    
    private let customStackView: CustomStackView = {
        let view = CustomStackView(frame: .zero)
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupView()
        configureView()
    }
    
    func setupView() {
        view.addSubview(containerView)
        containerView.clipsToBounds = true
        NSLayoutConstraint.activate([
            containerView.topAnchor.constraint(equalTo: view.topAnchor, constant: 40),
            containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])
        
        containerView.addSubview(customStackView)

        NSLayoutConstraint.activate([
            customStackView.topAnchor.constraint(equalTo: containerView.topAnchor),
            customStackView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
            customStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
            customStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
        ])
    }
    
    func configureView() {
        customStackView.stackView.addArrangedSubview(CellView(title: "Title"))
        customStackView.stackView.addArrangedSubview(CellView(title: "Tesla"))
        customStackView.stackView.addArrangedSubview(CellView(title: "Lucid"))
        customStackView.stackView.addArrangedSubview(CellView(title: "Merc"))
        customStackView.stackView.addArrangedSubview(CellView(title: "BMW"))
    }
}

I would like the view to behave in this way when it reaches the top:

enter image description here

kaddie
  • 233
  • 1
  • 5
  • 27
  • What do you want to happen when there's not enough space to fit everything? – Sweeper Apr 16 '21 at 12:04
  • The stackView should scroll if it reaches the top of the view – kaddie Apr 16 '21 at 12:06
  • I have updated with a gif that describes the behaviour – kaddie Apr 16 '21 at 12:14
  • You seem to not have set the content size for the scroll view. – Sweeper Apr 16 '21 at 12:23
  • Im not sure what you mean , do you mind giving an example of how is should do it – kaddie Apr 16 '21 at 12:37
  • See [this](https://stackoverflow.com/questions/50847601/view-at-the-bottom-in-a-uiscrollview-with-autolayout). See what constraints it adds to the footer, and also [this](https://stackoverflow.com/questions/19036228/uiscrollview-scrollable-content-size-ambiguity), which is probably the error that you are getting. – Sweeper Apr 16 '21 at 13:01

1 Answers1

2

To get your "stack of CellViews" to grow from the bottom, you need to embed that stackView in another "container" view.

  • Add the stackView as a subview of the stackContainer view
  • constrain the stackView Leading / Trailing / Bottom all equal to Zero
  • constrain the stackView Top greater-than-or-equal to Zero... that will keep the stackView at the bottom, but will force the stackContainer to grow when it gets tall enough
  • Add the stackContainer view as a subview to the scrollView
  • constrain all 4 sides of that stackContainer view to the scrollView's .contentLayoutGuide to control the "scrollable area."
  • constrain the stackContainer's width to the scrollView's .frameLayoutGuide width

The "tricky" part is this: we also constrain the stackContainer view's height equal to the scrollView's .frameLayoutGuide height, but we set the priority of that constraint to less-than-required -- such as .defaultHigh. This will keep the stackContainer view equal to the height of the scroll view's frame, until the stack view gets tall enough to make it grow.

Here's how it looks when running:

enter image description here

Red:    your main "container" view
Green:  CustomStackView
Yellow: scrollView
Teal:   stackContainerView
Orange: stack view
Gray:   CellViews

Here's your code with some modifications...


CustomStackView -- see the comments in the code:

open class CustomStackView: UIView {
    
    public var scrollView: UIScrollView = {
        
        let view = UIScrollView(frame: .zero)
        view.isScrollEnabled = true
        view.bounces = true
        view.alwaysBounceVertical = true
        view.keyboardDismissMode = .interactive
        view.layoutMargins = .zero
        view.clipsToBounds = false
        
        return view
    }()

    // a UIView to hold the stack view
    let stackContainerView: UIView = {
        let v = UIView()
        v.backgroundColor = .systemTeal
        return v
    }()

    // func to "auto-scroll" to the bottom of the scroll view
    public func scrollToBottom() -> Void {
        let sz = scrollView.contentSize
        let offset = sz.height - scrollView.frame.height
        UIView.animate(withDuration: 0.3, animations: {
            self.scrollView.contentOffset.y = offset
        })
    }

    public var stackView: UIStackView = {
        
        let stackView = UIStackView(frame: .zero)
        stackView.axis = .vertical
        stackView.alignment = .fill
        stackView.distribution = .fill //Proportionally
        
        let gap: CGFloat = 0
        stackView.spacing = gap
        stackView.layoutMargins = UIEdgeInsets(top: gap, left: gap, bottom: gap, right: gap)
        stackView.isLayoutMarginsRelativeArrangement = true
        
        return stackView
    }()
    
    override public init(frame: CGRect) {
        
        super.init(frame: frame)
        
        self.layoutMargins = .zero
        
        backgroundColor = .green
        
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        self.addSubview(scrollView)
        
        NSLayoutConstraint.activate([scrollView.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor),
                                     scrollView.trailingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.trailingAnchor),
                                     scrollView.topAnchor.constraint(equalTo: self.safeAreaLayoutGuide.topAnchor),
                                     scrollView.bottomAnchor.constraint(equalTo: self.safeAreaLayoutGuide.bottomAnchor)])

        stackContainerView.translatesAutoresizingMaskIntoConstraints = false
        stackView.translatesAutoresizingMaskIntoConstraints = false

        // add stackContainer to the scrollView
        scrollView.addSubview(stackContainerView)
        // add stackView to the stackContainer
        stackContainerView.addSubview(stackView)
        
        let contentGuide = scrollView.contentLayoutGuide
        let frameGuide = scrollView.frameLayoutGuide

        NSLayoutConstraint.activate([
            // constrain stackContainer sides to scrollView's .contentLayoutGuide
            stackContainerView.leadingAnchor.constraint(equalTo: contentGuide.leadingAnchor),
            stackContainerView.trailingAnchor.constraint(equalTo: contentGuide.trailingAnchor),
            stackContainerView.topAnchor.constraint(equalTo: contentGuide.topAnchor),
            stackContainerView.bottomAnchor.constraint(equalTo: contentGuide.bottomAnchor),
            
            // constrain stackContainer width to scrollView's .frameLayoutGuide
            stackContainerView.widthAnchor.constraint(equalTo: frameGuide.widthAnchor),
            
            // constrain stackView sides to stackContainer
            stackView.leadingAnchor.constraint(equalTo: stackContainerView.leadingAnchor),
            stackView.trailingAnchor.constraint(equalTo: stackContainerView.trailingAnchor),
            
            // constrain stackView to bottom of container
            stackView.bottomAnchor.constraint(equalTo: stackContainerView.bottomAnchor),

            // keep stackView Top >= stackContainer Top
            stackView.topAnchor.constraint(greaterThanOrEqualTo: stackContainerView.topAnchor),
        ])
        
        // constrain stackContainer Height to scrollView's .frameLayoutGuide Height
        let cHeight = stackContainerView.heightAnchor.constraint(equalTo: frameGuide.heightAnchor)
        // give it less-than .required Priority, so it can grow
        //  when the stackView gets taller
        cHeight.priority = .defaultHigh
        // activate this constraint
        cHeight.isActive = true
        
        // so we can see view frames for debugging during dev
        scrollView.backgroundColor = .yellow
        stackView.backgroundColor = .orange

    }
    
    required public init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

TestViewController -- only change is the addition of a tap gesture recognizer... each tap will add a new CellView to the bottom of the stack:

class TestViewController: UIViewController {
    
    private let containerView: UIView = {
        let view = UIView(frame: .zero)
        view.translatesAutoresizingMaskIntoConstraints = false
        view.backgroundColor = UIColor.red
        view.clipsToBounds = true
        return view
    }()
    
    private let customStackView: CustomStackView = {
        let view = CustomStackView(frame: .zero)
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupView()
        configureView()
        
        // add a tap gesture recognizer
        let t = UITapGestureRecognizer(target: self, action: #selector(didTap(_:)))
        view.addGestureRecognizer(t)
    }
    
    @objc func didTap(_ g: UITapGestureRecognizer) -> Void {
        // on tap, get the current number of "cell views" in the custom stack view
        let n = customStackView.stackView.arrangedSubviews.count
        // add a new CellView
        customStackView.stackView.addArrangedSubview(CellView(title: "Cell \(n)"))
        // scroll the stack view to the bottom to show the newly added CellView
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
            self.customStackView.scrollToBottom()
        })
    }
    
    func setupView() {
        view.addSubview(containerView)
        containerView.clipsToBounds = true
        NSLayoutConstraint.activate([
            containerView.topAnchor.constraint(equalTo: view.topAnchor, constant: 40),
            containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])
        
        containerView.addSubview(customStackView)
        
        NSLayoutConstraint.activate([
            customStackView.topAnchor.constraint(equalTo: containerView.topAnchor),
            customStackView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
            customStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
            customStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
        ])
    }
    
    func configureView() {
        customStackView.stackView.addArrangedSubview(CellView(title: "Title"))
        customStackView.stackView.addArrangedSubview(CellView(title: "Tesla"))
        customStackView.stackView.addArrangedSubview(CellView(title: "Lucid"))
        customStackView.stackView.addArrangedSubview(CellView(title: "Merc"))
        customStackView.stackView.addArrangedSubview(CellView(title: "BMW"))
    }
}

CellView -- no changes, just including it here for completion's sake:

class CellView: UIView {
    
    private let title: UILabel = {
        let label = UILabel(frame: .zero)
        label.textColor = .black
        label.font = .systemFont(ofSize: 18, weight: .medium)
        label.translatesAutoresizingMaskIntoConstraints = false
        label.textAlignment = .left
        label.text = "Title"
        return label
    }()
    
    private let subTitle: UILabel = {
        let label = UILabel(frame: .zero)
        label.textColor = .lightGray
        label.font = .systemFont(ofSize: 14, weight: .regular)
        label.translatesAutoresizingMaskIntoConstraints = false
        label.text = "subTitle"
        label.isHidden = true
        return label
    }()
    
    private let seperatorView: UIView = {
        let view = UIView(frame: .zero)
        view.backgroundColor = .lightGray
        view.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
        view.translatesAutoresizingMaskIntoConstraints = false
        
        return view
    }()
    
    private let headerStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.alignment = .leading
        stackView.distribution = .fill
        stackView.spacing = 0
        stackView.translatesAutoresizingMaskIntoConstraints = false
        return stackView
    }()
    
    
    override func awakeFromNib() {
        super.awakeFromNib()
        // Initialization code
    }
    
    override init(frame: CGRect) {
        super.init(frame: .zero)
        
        setupView()
    }
    
    init(title: String) {
        
        super.init(frame: .zero)
        
        setupView()
        configureView(with: title)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupView() {
        
        self.backgroundColor = .gray
        
        headerStackView.addArrangedSubview(title)
        headerStackView.addArrangedSubview(subTitle)
        addSubview(seperatorView)
        
        addSubview(headerStackView)
        
        setupConstraints()
    }
    
    private func setupConstraints() {
        
        self.heightAnchor.constraint(equalToConstant: 50).isActive = true
        
        headerStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16).isActive = true
        headerStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor).isActive = true
        headerStackView.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
        
        seperatorView.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true
        seperatorView.leadingAnchor.constraint(equalTo: self.leadingAnchor).isActive = true
        seperatorView.trailingAnchor.constraint(equalTo: self.trailingAnchor).isActive = true
    }
    
    private func configureView(with title: String) {
        self.title.text = title
    }
    
}
DonMag
  • 69,424
  • 5
  • 50
  • 86