UIScrollView布局需求:按钮固定底部,内容过多时随内容滚动
问题描述
我需要在UIScrollView中实现如下布局:
- 标题、描述等标签依次垂直排列
- 按钮默认位于视图最底部
- 当内容(如描述文本过长)时,按钮需随内容滚动出视野,且紧邻描述文本下方
但当前实现未达到预期效果,还出现了无法解释的异常行为:

以下是我最新尝试的代码:
// // KitchenSinkView.swift // MBViews // // Created by Stefan Walkner on 26.04.24. // import UIKit class KitchenSink: UIView { private let scrollView = UIScrollView() private let titleLabel = UILabel() private let contentLabel = UILabel() private let actionButton = UIButton(type: .system) override init(frame: CGRect) { super.init(frame: frame) setupViews() setupConstraints() } required init?(coder: NSCoder) { super.init(coder: coder) setupViews() setupConstraints() } private func setupViews() { backgroundColor = .white // Setup scrollView scrollView.translatesAutoresizingMaskIntoConstraints = false addSubview(scrollView) // Setup titleLabel titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.text = "Title" titleLabel.font = UIFont.boldSystemFont(ofSize: 20) titleLabel.backgroundColor = .blue titleLabel.setContentHuggingPriority(.required, for: .vertical) // Setup contentLabel contentLabel.translatesAutoresizingMaskIntoConstraints = false contentLabel.text = """ asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf """ contentLabel.numberOfLines = 0 contentLabel.backgroundColor = .green contentLabel.setContentHuggingPriority(.required, for: .vertical) // Setup actionButton actionButton.translatesAutoresizingMaskIntoConstraints = false actionButton.setTitle("Tap Me", for: .normal) actionButton.backgroundColor = .red actionButton.setContentHuggingPriority(.required, for: .vertical) // Add subviews to scrollView scrollView.addSubview(titleLabel) scrollView.addSubview(contentLabel) scrollView.addSubview(actionButton) } private var buttonBottomConstraint: NSLayoutConstraint? private func setupConstraints() { NSLayoutConstraint.activate([ scrollView.topAnchor.constraint(equalTo: topAnchor), scrollView.leftAnchor.constraint(equalTo: leftAnchor), scrollView.rightAnchor.constraint(equalTo: rightAnchor), scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), titleLabel.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 20), titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), contentLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 20), contentLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), contentLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), actionButton.topAnchor.constraint(equalTo: contentLabel.bottomAnchor, constant: 20), actionButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), actionButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20) ]) buttonBottomConstraint = actionButton.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -20) buttonBottomConstraint?.isActive = true } override func layoutSubviews() { super.layoutSubviews() adjustButtonPosition() } private func adjustButtonPosition() { let scrollViewHeight = scrollView.frame.size.height let contentHeight = titleLabel.intrinsicContentSize.height + contentLabel.intrinsicContentSize.height + 60 // total vertical padding + button height if contentHeight < scrollViewHeight { // If the content + padding is less than the height of the scroll view, anchor button to bottom of the screen buttonBottomConstraint?.isActive = false buttonBottomConstraint = actionButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20) buttonBottomConstraint?.isActive = true } else { // Content is enough to require scrolling, anchor button to content buttonBottomConstraint?.isActive = false buttonBottomConstraint = actionButton.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -20) buttonBottomConstraint?.isActive = true } } } class KitchenSinkViewController: UIViewController { private var customView: KitchenSink! override func loadView() { customView = KitchenSink() view = customView } } #Preview { KitchenSinkViewController() }
解决方案
你当前的实现问题在于手动切换约束时容易引发布局冲突,且计算内容高度的方式忽略了按钮自身高度和布局的动态变化。通过UIStackView结合优先级约束可以更优雅地实现需求:
修改步骤
- 用垂直
UIStackView包裹标题、描述和按钮,统一管理垂直间距和排列 - 给按钮添加一个低优先级的底部对齐约束:当内容高度不足容器高度时,该约束生效将按钮固定在底部;当内容足够长时,该约束会被栈视图的布局挤压失效,按钮自然跟随内容下方
- 移除
layoutSubviews中手动切换约束的逻辑,避免布局冲突
修改后完整代码
// // KitchenSinkView.swift // MBViews // // Created by Stefan Walkner on 26.04.24. // import UIKit class KitchenSink: UIView { private let scrollView = UIScrollView() private let titleLabel = UILabel() private let contentLabel = UILabel() private let actionButton = UIButton(type: .system) // 添加垂直栈视图 private let stackView = UIStackView() override init(frame: CGRect) { super.init(frame: frame) setupViews() setupConstraints() } required init?(coder: NSCoder) { super.init(coder: coder) setupViews() setupConstraints() } private func setupViews() { backgroundColor = .white // Setup scrollView scrollView.translatesAutoresizingMaskIntoConstraints = false addSubview(scrollView) // Setup stackView stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical stackView.spacing = 20 // 统一设置垂直间距 scrollView.addSubview(stackView) // Setup titleLabel titleLabel.text = "Title" titleLabel.font = UIFont.boldSystemFont(ofSize: 20) titleLabel.backgroundColor = .blue // Setup contentLabel contentLabel.text = """ asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf asdf """ contentLabel.numberOfLines = 0 contentLabel.backgroundColor = .green // Setup actionButton actionButton.setTitle("Tap Me", for: .normal) actionButton.backgroundColor = .red // 将控件添加到栈视图 stackView.addArrangedSubview(titleLabel) stackView.addArrangedSubview(contentLabel) stackView.addArrangedSubview(actionButton) } private func setupConstraints() { NSLayoutConstraint.activate([ // 滚动视图铺满整个容器 scrollView.topAnchor.constraint(equalTo: topAnchor), scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), // 栈视图约束 stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 20), stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 20), stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -20), stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -20), // 栈视图宽度和滚动视图框架宽度一致,避免水平滚动 stackView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -40), // 按钮的低优先级底部约束:当内容不足时,按钮固定在容器底部 actionButton.bottomAnchor.constraint(equalTo: scrollView.frameLayoutGuide.bottomAnchor, constant: -20) .priority = .defaultLow ]) } } class KitchenSinkViewController: UIViewController { private var customView: KitchenSink! override func loadView() { customView = KitchenSink() view = customView } } #Preview { KitchenSinkViewController() }
原理说明
UIStackView自动管理子视图的垂直排列和间距,避免手动计算间距- 低优先级的底部约束:当栈视图总高度小于滚动视图高度时,这个约束会把按钮拉到容器底部;当栈视图高度超过滚动视图高度时,该约束优先级低于栈视图的布局约束,会自动失效,按钮就会跟随内容滚动
- 给栈视图设置宽度等于滚动视图框架宽度,确保不会出现水平滚动
内容的提问来源于stack exchange,提问作者swalkner
相关产品推荐
相关产品推荐

