You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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结合优先级约束可以更优雅地实现需求:

修改步骤

  1. 用垂直UIStackView包裹标题、描述和按钮,统一管理垂直间距和排列
  2. 给按钮添加一个低优先级的底部对齐约束:当内容高度不足容器高度时,该约束生效将按钮固定在底部;当内容足够长时,该约束会被栈视图的布局挤压失效,按钮自然跟随内容下方
  3. 移除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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.24 21:48:10