mcpField
P
programmatic-uikit-layout

programmatic-uikit-layout

github.com/dagba开发443 次安装40 周前更新
代码仓库

关于 programmatic-uikit-layout

面向编写 UIKit 界面的开发者,这份技能指南介绍如何用 Auto Layout 锚点和 UIStackView 构建纯代码布局,并通过主题常量与可复用组件提升一致性。

主要功能

  1. 01使用类型安全的布局锚点
  2. 02优先采用 UIStackView 组合
  3. 03遵循安全区域布局
  4. 04集中管理间距与颜色
  5. 05构建可复用界面组件
  6. 06调整内容伸缩优先级

适用场景

  1. 01从 Storyboard 迁移到纯代码界面
  2. 02排列横向或纵向视图
  3. 03统一多个页面的间距样式
  4. 04排查约束冲突与模糊布局

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dagba/ios-mcp --skill programmatic-uikit-layout

安装后在 Claude Code 中输入/programmatic-uikit-layout确认已加载。

详细介绍

使用 Auto Layout 以编程方式布局 UIKit

概述

使用锚点以编程方式布局 UIKit,可以编写类型安全、易于维护的 UI 代码,适用于大规模生产应用。 堆栈视图可解决 50% 以上的布局问题,锚点提供编译时安全性,设计系统则确保一致性。

核心原则: 仅使用锚点(不使用 Visual Format Language),优先组合堆栈视图,并使用集中管理间距和颜色的可复用组件。

基础设置

步骤 1:禁用 Storyboard

移除 Main.storyboard:

  1. 删除 Main.storyboard 文件
  2. 在 Info.plist 中删除“Main storyboard file base name”条目
  3. 在 Target → Info → Custom iOS Target Properties 中删除“Storyboard Name”

配置 SceneDelegate:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }

        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = MainViewController()
        window?.makeKeyAndVisible()
    }
}
步骤 2:UIView 基本设置

重要: 每个视图都需要设置 translatesAutoresizingMaskIntoConstraints = false

// ❌ WRONG: Constraints conflict with autoresizing mask
let label = UILabel()
view.addSubview(label)
label.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
// Runtime error: conflicting constraints

// ✅ CORRECT: Disable autoresizing mask
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
label.topAnchor.constraint(equalTo: view.topAnchor).isActive = true

规则: 在添加约束之前,先设置 translatesAutoresizingMaskIntoConstraints = false。

布局锚点(首选方法)

为什么选择锚点而不是 NSLayoutConstraint
特性布局锚点NSLayoutConstraint
类型安全✅ 编译时检查❌ 运行时错误
可读性✅ 流畅的 API❌ 冗长的初始化器
防止错误✅ 无法混用 X/Y 轴❌ 容易出错
// ❌ WRONG: NSLayoutConstraint (verbose, error-prone)
NSLayoutConstraint(
    item: label,
    attribute: .top,
    relatedBy: .equal,
    toItem: view,
    attribute: .top,
    multiplier: 1.0,
    constant: 20
).isActive = true

// ✅ CORRECT: Layout anchors (concise, type-safe)
label.topAnchor.constraint(equalTo: view.topAnchor, constant: 20).isActive = true
锚点类型

X 轴: leadingAnchor、trailingAnchor、leftAnchor、rightAnchor、centerXAnchor Y 轴: topAnchor、bottomAnchor、centerYAnchor 尺寸: widthAnchor、heightAnchor

类型安全:

// ✅ Compiles: Same axis
label.leadingAnchor.constraint(equalTo: view.leadingAnchor)

// ❌ Compiler error: Mixed axes
label.leadingAnchor.constraint(equalTo: view.topAnchor)  // Won't compile!
基本约束模式

固定到边缘:

label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)

NSLayoutConstraint.activate([
    label.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
    label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
    label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
    label.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -20)
])

居中并设置尺寸:

label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)

NSLayoutConstraint.activate([
    label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
    label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
    label.widthAnchor.constraint(equalToConstant: 200),
    label.heightAnchor.constraint(equalToConstant: 50)
])

宽高比:

imageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(imageView)

NSLayoutConstraint.activate([
    imageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
    imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
    imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
    imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 9.0/16.0)  // 16:9
])

安全区域布局指南

重要: 在现代 iPhone(刘海屏、灵动岛)上使用 safeAreaLayoutGuide。

// ❌ WRONG: Content hidden behind notch
label.topAnchor.constraint(equalTo: view.topAnchor)

// ✅ CORRECT: Respects safe area
label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)

标准模式:

NSLayoutConstraint.activate([
    contentView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
    contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
    contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
    contentView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
])

堆栈视图(优先使用堆栈视图)

重要: 堆栈视图可解决 50% 以上的布局问题。请广泛使用。

digraph layout_choice {
    "Need to arrange multiple views?" [shape=diamond];
    "All in line (row/column)?" [shape=diamond];
    "Use UIStackView" [shape=box, style=filled, fillcolor=lightgreen];
    "Use nested UIStackViews" [shape=box, style=filled, fillcolor=lightblue];
    "Use anchors" [shape=box, style=filled, fillcolor=yellow];

    "Need to arrange multiple views?" -> "All in line (row/column)?" [label="yes"];
    "Need to arrange multiple views?" -> "Use anchors" [label="no"];
    "All in line (row/column)?" -> "Use UIStackView" [label="yes"];
    "All in line (row/column)?" -> "Use nested UIStackViews" [label="no (complex grid)"];
}
基本堆栈视图
let stackView = UIStackView()
stackView.axis = .vertical  // or .horizontal
stackView.spacing = 16
stackView.alignment = .fill  // .leading, .center, .trailing
stackView.distribution = .fill  // .fillEqually, .equalSpacing, etc.
stackView.translatesAutoresizingMaskIntoConstraints = false

view.addSubview(stackView)
NSLayoutConstraint.activate([
    stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
    stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
    stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
])

stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(subtitleLabel)
stackView.addArrangedSubview(button)
嵌套堆栈视图(复杂布局)
// Horizontal stack with icon + labels
let horizontalStack = UIStackView()
horizontalStack.axis = .horizontal
horizontalStack.spacing = 12
horizontalStack.alignment = .center

let iconImageView = UIImageView(image: UIImage(systemName: "star.fill"))
iconImageView.widthAnchor.constraint(equalToConstant: 24).isActive = true
iconImageView.heightAnchor.constraint(equalToConstant: 24).isActive = true

// Vertical stack for title + subtitle
let verticalStack = UIStackView()
verticalStack.axis = .vertical
verticalStack.spacing = 4

verticalStack.addArrangedSubview(titleLabel)
verticalStack.addArrangedSubview(subtitleLabel)

horizontalStack.addArrangedSubview(iconImageView)
horizontalStack.addArrangedSubview(verticalStack)

view.addSubview(horizontalStack)
// Pin horizontalStack with anchors
间隔视图
// Add flexible spacing in stack view
let spacer = UIView()
spacer.setContentHuggingPriority(.defaultLow, for: .vertical)
stackView.addArrangedSubview(spacer)

// Pattern: Button at bottom
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(descriptionLabel)
stackView.addArrangedSubview(spacer)  // Pushes button to bottom
stackView.addArrangedSubview(button)

设计系统模式

主题(集中管理间距和颜色)
enum Theme {
    enum Spacing {
        static let tiny: CGFloat = 4
        static let small: CGFloat = 8
        static let medium: CGFloat = 16
        static let large: CGFloat = 24
        static let xLarge: CGFloat = 32
    }

    enum CornerRadius {
        static let small: CGFloat = 4
        static let medium: CGFloat = 8
        static let large: CGFloat = 12
        static let xLarge: CGFloat = 16
    }

    enum Colors {
        static let primary = UIColor.systemBlue
        static let secondary = UIColor.systemGray
        static let background = UIColor.systemBackground
        static let text = UIColor.label
        static let textSecondary = UIColor.secondaryLabel
    }
}

// Usage:
stackView.spacing = Theme.Spacing.medium
button.layer.cornerRadius = Theme.CornerRadius.medium
label.textColor = Theme.Colors.text
可复用组件

卡片视图:

final class CardView: UIView {
    private let containerView = UIView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) not implemented")
    }

    private func setupView() {
        backgroundColor = Theme.Colors.background
        layer.cornerRadius = Theme.CornerRadius.large
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowOpacity = 0.1
        layer.shadowOffset = CGSize(width: 0, height: 2)
        layer.shadowRadius = 4

        containerView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(containerView)

        NSLayoutConstraint.activate([
            containerView.topAnchor.constraint(equalTo: topAnchor, constant: Theme.Spacing.medium),
            containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Theme.Spacing.medium),
            containerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Theme.Spacing.medium),
            containerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Theme.Spacing.medium)
        ])
    }

    func setContent(_ view: UIView) {
        containerView.subviews.forEach { $0.removeFromSuperview() }
        view.translatesAutoresizingMaskIntoConstraints = false
        containerView.addSubview(view)

        NSLayoutConstraint.activate([
            view.topAnchor.constraint(equalTo: containerView.topAnchor),
            view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
            view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
            view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
        ])
    }
}

主按钮:

final class PrimaryButton: UIButton {
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupButton()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) not implemented")
    }

    private func setupButton() {
        backgroundColor = Theme.Colors.primary
        setTitleColor(.white, for: .normal)
        layer.cornerRadius = Theme.CornerRadius.medium
        titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
        contentEdgeInsets = UIEdgeInsets(
            top: Theme.Spacing.medium,
            left: Theme.Spacing.large,
            bottom: Theme.Spacing.medium,
            right: Theme.Spacing.large
        )
    }
}

布局优先级与内容抱紧/压缩

内容抱紧优先级

控制视图对超出其固有内容尺寸的扩展的抵抗程度。

// Default: 250 (low)
// Higher priority = resists growing more

label.setContentHuggingPriority(.required, for: .horizontal)  // 1000
button.setContentHuggingPriority(.defaultLow, for: .horizontal)  // 250

// Result: Button expands, label stays at intrinsic width
内容压缩阻力

控制视图对缩小到其固有内容尺寸以下的抵抗程度。

// Default: 750 (high)
// Higher priority = resists shrinking more

titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)  // 1000
subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)  // 250

// Result: Subtitle truncates before title

常见模式:水平堆栈中的标签

let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 8

let label = UILabel()
label.text = "Very long text that might truncate..."
label.setContentHuggingPriority(.defaultLow, for: .horizontal)  // Allows expansion
label.setContentCompressionResistancePriority(.required, for: .horizontal)  // Prevents shrinking

let button = UIButton()
button.setContentHuggingPriority(.required, for: .horizontal)  // Stays at intrinsic width

stackView.addArrangedSubview(label)
stackView.addArrangedSubview(button)

常见错误

错误实际情况修正方法
“忘记设置 translatesAutoresizingMaskIntoConstraints = false”约束冲突,布局出错在添加约束之前设为 false
“使用 view.topAnchor 而不是 safeAreaLayoutGuide”内容被刘海遮挡始终使用 safeAreaLayoutGuide
“为每个约束单独设置 .isActive = true”冗长且低效使用 NSLayoutConstraint.activate([])
“Visual Format Language 更简单”VFL 已弃用且容易出错仅使用锚点
“不需要内容抱紧/压缩设置”标签意外截断,按钮扩展异常显式设置优先级
“添加约束后再添加子视图”崩溃:视图不在层级结构中在 activate() 之前调用 addSubview()

布局问题调试

布局不明确
// In UIViewController viewDidLoad or custom view init
#if DEBUG
DispatchQueue.main.async {
    if self.view.hasAmbiguousLayout {
        print("⚠️ Ambiguous layout detected")
        self.view.exerciseAmbiguityInLayout()  // Animate between valid layouts
    }
}
#endif
约束冲突

运行时错误: “Unable to simultaneously satisfy constraints...”

调试:

  1. 查看错误消息中的约束 ID
  2. 为约束添加标识符:
let constraint = label.topAnchor.constraint(equalTo: view.topAnchor)
constraint.identifier = "label-top"
constraint.isActive = true
  1. 降低不太重要的约束的优先级:
let constraint = label.heightAnchor.constraint(equalToConstant: 50)
constraint.priority = .defaultHigh  // 750 instead of 1000
constraint.isActive = true

快速参考

基本设置:

let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
parentView.addSubview(view)

NSLayoutConstraint.activate([
    view.topAnchor.constraint(equalTo: parentView.safeAreaLayoutGuide.topAnchor),
    view.leadingAnchor.constraint(equalTo: parentView.leadingAnchor),
    view.trailingAnchor.constraint(equalTo: parentView.trailingAnchor),
    view.bottomAnchor.constraint(equalTo: parentView.bottomAnchor)
])

堆栈视图:

let stack = UIStackView()
stack.axis = .vertical
stack.spacing = Theme.Spacing.medium
stack.addArrangedSubview(view1)
stack.addArrangedSubview(view2)

设计系统:

view.backgroundColor = Theme.Colors.background
stackView.spacing = Theme.Spacing.medium
button.layer.cornerRadius = Theme.CornerRadius.large

危险信号——停止并重新考虑

  • 使用 Visual Format Language → 改用锚点
  • 未禁用 translatesAutoresizingMaskIntoConstraints → 约束冲突
  • 忽略安全区布局指南 → 内容在新款 iPhone 上被遮挡
  • 手动创建 5 个以上约束 → 考虑使用 UIStackView
  • 到处散落硬编码的间距值(8、16、24)→ 集中放到 Theme 中
  • 单独激活约束 → 使用 activate([]) 批量激活
  • 生产环境中的布局不明确 → 添加固有尺寸或补上缺失的约束

真实案例的影响

之前: 混用 VFL、锚点和原始 NSLayoutConstraint。个人资料页面有 500 多行代码。约束冲突频繁。

之后: 仅使用锚点和堆栈视图。200 行代码,没有冲突,也更易维护。


之前: 30 个视图控制器中到处都是硬编码的间距(8px、12px、16px)。更新设计 = 编辑 30 个文件。

之后: Theme.Spacing.medium。更新设计 = 编辑 1 个文件,影响整个应用。


之前: 为卡片布局编写自定义的复杂约束逻辑。100 行代码,难以调试。

之后: 可复用的 CardView 组件。30 行代码,在 15 个地方统一使用。

来源

评论

支持 Markdown