如何以从下到上增长的方式将 stackView 固定到视图

问题描述

我在 scrollView 中有一个 stackView,我想固定 stackView,使其从底部开始并随着添加更多视图而向上增长。

显示当前行为的图像,显​​示固定在视图顶部的 stackView 以及从顶部到底部内容

Current behaviour

显示所需行为的图像,显​​示堆栈视图从下到上增长。

Desired behvaiour

当前代码

自定义堆栈视图


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
    }

}

视图控制器:

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"))
    }
}

我希望视图在到达顶部时以这种方式运行:

enter image description here

解决方法

为了让您的“CellViews 堆栈”从底部开始增长,您需要将该 stackView 嵌入另一个“容器”视图中。

  • 将stackView添加为stackContainer视图的子视图
  • 将 stackView 前导/尾随/底部限制为零
  • 将 stackView 顶部 大于或等于 限制为零...这将使 stackView 保持在底部,但会强制 stackContainer 在它足够高时增长
  • 将stackContainer视图作为子视图添加到scrollView
  • 将该 stackContainer 视图的所有 4 个边限制到 scrollView 的 .contentLayoutGuide 以控制“可滚动区域”。
  • 将 stackContainer 的 width 限制为 scrollView 的 .frameLayoutGuide 宽度

“棘手”的部分是这样的:我们还限制 stackContainer 视图的 height 等于 scrollView 的 .frameLayoutGuide 高度,但我们将该约束的优先级设置为小于要求-- 例如 .defaultHigh。这将使 stackContainer 视图保持等于滚动视图框架的高度,直到堆栈视图变得足够高以使其增长

这是运行时的样子:

enter image description here

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

这是经过一些修改的代码...


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
    }()

    // 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 -- 唯一的变化是添加了点击手势识别器...每次点击都会在堆栈底部添加一个新的 CellView

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 -- 没有变化,只是为了完成将它包含在这里:

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
    }
    
}