Chapter 03

布局系统:Stack、Grid 与 ScrollView

掌握 SwiftUI 的核心布局容器,从线性排列到网格布局,构建任意复杂的界面结构

3.1 布局的三种 Stack

Stack 是 SwiftUI 最基础的布局容器。三种 Stack 分别对应不同的排列方向,它们可以任意嵌套,构成复杂界面。

容器排列方向典型用途
HStack水平排列(左→右)工具栏、标题行、标签+值
VStack垂直排列(上→下)列表项、表单、页面主结构
ZStack深度叠加(后→前)遮罩、徽标、卡片覆盖层

HStack — 水平排列

HStack 将子视图从左到右水平排列。通过 spacing 参数控制子视图之间的间距,通过 alignment 参数控制垂直对齐方式。

// 基本用法:水平排列图标和文字
HStack {
    Image(systemName: "star.fill")
        .foregroundColor(.yellow)
    Text("收藏")
}

// spacing:设置子视图间距(默认约8pt)
HStack(spacing: 16) {
    Text("左")
    Text("中")
    Text("右")
}

// spacing: 0 消除间距
HStack(spacing: 0) {
    Rectangle().fill(.red).frame(height: 4)
    Rectangle().fill(.green).frame(height: 4)
    Rectangle().fill(.blue).frame(height: 4)
}

// alignment:垂直对齐方式
HStack(alignment: .top) {     // .top / .center / .bottom / .firstTextBaseline
    Text("小字").font(.caption)
    Text("大字").font(.largeTitle)
}

VStack — 垂直排列

VStack 将子视图从上到下垂直排列。alignment 参数控制水平对齐方式。

// alignment:水平对齐方式
VStack(alignment: .leading, spacing: 8) {
    Text("标题").font(.headline)
    Text("这是副标题,比较长一些").font(.subheadline)
    Text("正文内容").font(.body).foregroundColor(.secondary)
}
// alignment 可选值:
// .leading  — 左对齐
// .center   — 居中(默认)
// .trailing — 右对齐

ZStack — 深度叠加

ZStack 将子视图沿 Z 轴叠加,后写的视图显示在前面(更靠近用户)。常用于实现遮罩、角标等效果。

// 卡片上叠加角标
ZStack(alignment: .topTrailing) {
    // 底层:卡片主体
    RoundedRectangle(cornerRadius: 12)
        .fill(Color.blue.opacity(0.1))
        .frame(width: 120, height: 80)

    // 上层:未读角标
    Circle()
        .fill(.red)
        .frame(width: 20, height: 20)
        .overlay {
            Text("3").font(.caption2).foregroundColor(.white)
        }
        .offset(x: 8, y: -8)
}

// alignment 参数控制叠加基准点
// .topLeading / .top / .topTrailing
// .leading   / .center / .trailing
// .bottomLeading / .bottom / .bottomTrailing

3.2 Spacer 与 Divider

Spacer — 弹性填充

Spacer 是一个透明的弹性视图,会尽可能地占据所有剩余空间。它是实现"把元素推到两端"等布局效果的核心工具。

// 把按钮推到两端
HStack {
    Button("取消") { }
    Spacer()        // 撑开中间空间
    Button("确定") { }
}

// 把内容推到顶部
VStack {
    Text("我在顶部")
    Spacer()        // 推到底部的空间
}
.frame(maxHeight: .infinity)

// minLength:Spacer 的最小尺寸(防止被压缩为0)
Spacer(minLength: 20)

Divider — 分割线

Divider 绘制一条分割线。在 HStack 中是垂直线,在 VStack 中是水平线。

// 垂直分割线(在 HStack 中)
HStack {
    Text("左")
    Divider()
    Text("右")
}
.frame(height: 40)

// 水平分割线(在 VStack 中)
VStack {
    Text("第一节")
    Divider()
    Text("第二节")
}

3.3 padding 与 frame

这两个修饰符是布局的基础,理解它们的区别和配合方式非常重要。

ℹ️
概念区分

padding 在视图内部添加空白区域(类似 CSS padding);frame 则给视图指定一个期望的外部尺寸,视图会在这个框架内对齐。

padding — 内边距

// 所有方向 16pt 内边距
Text("你好").padding(16)

// 系统默认内边距(约 16pt)
Text("你好").padding()

// 指定方向
Text("你好")
    .padding(.horizontal, 20)   // 水平方向 20pt
    .padding(.vertical, 10)     // 垂直方向 10pt
    .padding(.top, 8)           // 仅顶部

// 方向可选:.all / .horizontal / .vertical
//          .top / .bottom / .leading / .trailing

frame — 尺寸框架

// 固定尺寸
Image(systemName: "photo")
    .frame(width: 100, height: 100)

// 最小/最大尺寸:常用于响应式布局
Text("自适应宽度")
    .frame(maxWidth: .infinity)     // 尽可能宽
    .frame(minHeight: 44)          // 最小高度 44(标准触控区)

// alignment:内容在 frame 内的对齐方式
Text("左对齐")
    .frame(maxWidth: .infinity, alignment: .leading)
    .background(.yellow.opacity(0.3))

// 常见模式:全屏宽度按钮
Button("登录") { }
    .frame(maxWidth: .infinity)
    .padding()
    .background(.blue)
    .foregroundColor(.white)
    .cornerRadius(12)
💡
padding 和 frame 的执行顺序

SwiftUI 修饰符从内到外叠加。.padding().frame(width: 200) 表示先加内边距,再限制整体为 200pt 宽;.frame(width: 200).padding() 则先限定 200pt,外边的 padding 不影响内部宽度。

3.4 LazyVGrid 与 LazyHGrid

Lazy(懒加载)意味着只有当视图滚动进入屏幕可见区域时,对应的子视图才会被创建,适合展示大量数据。

GridItem — 列(行)定义

在使用网格布局前,需要先定义列(或行)的规格,使用 GridItem 数组描述。

GridItem 尺寸类型说明示例
.fixed(n)固定宽/高度每列固定 100pt
.flexible(minimum:maximum:)弹性尺寸,自动填充3列等宽自适应
.adaptive(minimum:maximum:)自适应列数,尽量多放每列至少 80pt,放尽量多列
// 定义列规格
let fixedColumns = [
    GridItem(.fixed(100)),   // 第1列 100pt
    GridItem(.fixed(100)),   // 第2列 100pt
    GridItem(.fixed(100))    // 第3列 100pt
]

// 3列等宽(弹性)
let flexibleColumns = [
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible())
]

// 简写:重复 N 个相同规格
let threeColumns = Array(repeating: GridItem(.flexible()), count: 3)

// 自适应:每列至少 80pt,列数自动计算
let adaptiveColumns = [GridItem(.adaptive(minimum: 80))]

LazyVGrid — 垂直方向网格(向下扩展)

let items = Array(1...20)   // 示例数据

ScrollView {
    LazyVGrid(
        columns: Array(repeating: GridItem(.flexible()), count: 3),
        spacing: 12               // 行间距
    ) {
        ForEach(items, id: \.self) { item in
            RoundedRectangle(cornerRadius: 8)
                .fill(Color.blue.opacity(0.3))
                .aspectRatio(1, contentMode: .fit)   // 保持正方形
                .overlay {
                    Text("\(item)").font(.headline)
                }
        }
    }
    .padding()
}

// alignment:网格整体的水平对齐方式
LazyVGrid(columns: threeColumns, alignment: .leading, spacing: 16) {
    // ...
}

LazyHGrid — 水平方向网格(向右扩展)

// 行规格(类似列但方向旋转)
let rows = [
    GridItem(.fixed(80)),
    GridItem(.fixed(80))
]

ScrollView(.horizontal) {
    LazyHGrid(rows: rows, spacing: 12) {
        ForEach(0..<20) { i in
            RoundedRectangle(cornerRadius: 8)
                .fill(Color.green.opacity(0.4))
                .frame(width: 80)
                .overlay {
                    Text("\(i)").font(.headline)
                }
        }
    }
    .padding()
}

3.5 ScrollView

ScrollView 允许内容超过屏幕尺寸时进行滚动。它本身不对子视图施加布局,需要配合 Stack 或 Grid 使用。

// 垂直滚动(默认)
ScrollView {
    VStack(spacing: 12) {
        ForEach(0..<50) { i in
            Text("第 \(i) 行")
                .frame(maxWidth: .infinity)
                .padding()
                .background(.gray.opacity(0.1))
                .cornerRadius(8)
        }
    }
    .padding()
}

// 水平滚动
ScrollView(.horizontal, showsIndicators: false) {
    HStack(spacing: 12) {
        ForEach(0..<20) { i in
            RoundedRectangle(cornerRadius: 10)
                .fill(Color.blue.opacity(0.2))
                .frame(width: 120, height: 80)
                .overlay { Text("卡片 \(i)") }
        }
    }
    .padding(.horizontal)
}

// 同时支持两个方向滚动
ScrollView([.horizontal, .vertical]) {
    // 超大内容区域
}

// iOS 17+ scrollPosition:记录/控制滚动位置
@State private var scrollPosition: Int? = nil

ScrollView {
    LazyVStack {
        ForEach(0..<100) { i in
            Text("行 \(i)").id(i)
        }
    }
}
.scrollPosition(id: $scrollPosition)

Button("滚到第 50 行") {
    withAnimation { scrollPosition = 50 }
}

3.6 GeometryReader

GeometryReader 是一个特殊的容器视图,它将父视图分配给自己的空间尺寸暴露给子视图,使子视图能基于父视图的实际大小来动态计算布局。

⚠️
谨慎使用

GeometryReader 会撑满父视图所有可用空间(贪婪布局),并且会引入额外的布局计算。只在真正需要获取父视图尺寸时使用,不要滥用。

// 获取父视图尺寸,制作宽度比例的进度条
GeometryReader { geometry in
    // geometry.size.width  — 可用宽度
    // geometry.size.height — 可用高度
    // geometry.frame(in: .global) — 在屏幕坐标中的 frame
    // geometry.frame(in: .local)  — 在自身坐标中的 frame

    ZStack(alignment: .leading) {
        Rectangle()
            .fill(.gray.opacity(0.3))
            .frame(height: 8)
        Rectangle()
            .fill(.blue)
            .frame(width: geometry.size.width * 0.6, height: 8)  // 60% 进度
    }
    .cornerRadius(4)
}
.frame(height: 8)

// 响应式图片:始终保持全宽
GeometryReader { geo in
    Image(systemName: "photo")
        .resizable()
        .frame(width: geo.size.width, height: geo.size.width * 0.5625)  // 16:9
}

3.7 安全区域(Safe Area)

iOS 设备有刘海(Dynamic Island)、Home Indicator 等物理特征,安全区域是屏幕上不被这些元素遮挡的区域。SwiftUI 默认让内容在安全区域内渲染。

// 让背景色延伸到安全区域(全屏背景)
ZStack {
    Color.blue.ignoresSafeArea()    // 背景扩展到边缘
    Text("内容仍在安全区域内")        // 文字不受影响
}

// 指定方向忽略安全区域
Color.green
    .ignoresSafeArea(.container, edges: .bottom)  // 仅底部

// safeAreaInset:在安全区域边缘插入额外视图(iOS 15+)
// 常用于在 ScrollView 底部悬浮一个按钮
ScrollView {
    ForEach(0..<30) { i in
        Text("内容 \(i)").padding()
    }
}
.safeAreaInset(edge: .bottom) {
    // 悬浮在底部的操作按钮,ScrollView 会自动为其留出空间
    Button("发布") { }
        .frame(maxWidth: .infinity)
        .padding()
        .background(.blue)
        .foregroundColor(.white)
        .padding(.horizontal)
        .background(.regularMaterial)  // 毛玻璃效果
}

3.8 实战示例:卡片网格布局

结合本章所有知识,构建一个常见的 App 首页卡片网格布局。

// 数据模型
struct AppItem: Identifiable {
    let id = UUID()
    let name: String
    let icon: String      // SF Symbol 名称
    let color: Color
    let description: String
}

// 单个卡片视图
struct AppCard: View {
    let item: AppItem

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            // 图标区域
            ZStack {
                RoundedRectangle(cornerRadius: 14)
                    .fill(item.color.opacity(0.15))
                    .frame(width: 52, height: 52)
                Image(systemName: item.icon)
                    .font(.system(size: 24))
                    .foregroundColor(item.color)
            }

            // 名称
            Text(item.name)
                .font(.headline)
                .lineLimit(1)

            // 简介
            Text(item.description)
                .font(.caption)
                .foregroundColor(.secondary)
                .lineLimit(2)

            Spacer()
        }
        .padding()
        .frame(maxWidth: .infinity, minHeight: 140, alignment: .topLeading)
        .background(.regularMaterial)
        .cornerRadius(16)
        .shadow(color: .black.opacity(0.06), radius: 8, y: 4)
    }
}

// 主页视图
struct HomeGridView: View {

    let apps: [AppItem] = [
        AppItem(name: "相机", icon: "camera.fill", color: .purple,
                description: "拍摄高质量照片和视频"),
        AppItem(name: "地图", icon: "map.fill", color: .green,
                description: "导航与位置服务"),
        AppItem(name: "音乐", icon: "music.note", color: .pink,
                description: "海量音乐在线聆听"),
        AppItem(name: "天气", icon: "cloud.sun.fill", color: .blue,
                description: "实时天气预报"),
        AppItem(name: "邮件", icon: "envelope.fill", color: .blue,
                description: "收发电子邮件"),
        AppItem(name: "备忘录", icon: "note.text", color: .yellow,
                description: "记录灵感和待办事项"),
    ]

    // 自适应列:每列至少 160pt
    let columns = [GridItem(.adaptive(minimum: 160), spacing: 12)]

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 20) {

                // 顶部横向滚动推荐区
                Text("精选推荐").font(.title2).fontWeight(.bold)
                    .padding(.horizontal)

                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 12) {
                        ForEach(0..<5) { i in
                            RoundedRectangle(cornerRadius: 14)
                                .fill(LinearGradient(
                                    colors: [.blue, .purple],
                                    startPoint: .topLeading,
                                    endPoint: .bottomTrailing
                                ))
                                .frame(width: 280, height: 160)
                                .overlay {
                                    Text("Banner \(i + 1)")
                                        .foregroundColor(.white)
                                        .font(.headline)
                                }
                        }
                    }
                    .padding(.horizontal)
                }

                // 网格区域
                Text("全部应用").font(.title2).fontWeight(.bold)
                    .padding(.horizontal)

                LazyVGrid(columns: columns, spacing: 12) {
                    ForEach(apps) { app in
                        AppCard(item: app)
                    }
                }
                .padding(.horizontal)
            }
            .padding(.top)
        }
        // 悬浮底部按钮
        .safeAreaInset(edge: .bottom) {
            HStack {
                Spacer()
                Button {
                    // 添加应用
                } label: {
                    Label("添加应用", systemImage: "plus.circle.fill")
                        .font(.headline)
                        .padding(.horizontal, 24)
                        .padding(.vertical, 14)
                        .background(.blue)
                        .foregroundColor(.white)
                        .clipShape(Capsule())
                        .shadow(radius: 8, y: 4)
                }
                Spacer()
            }
            .padding(.bottom, 8)
            .background(.regularMaterial)
        }
    }
}
小结

本章学习了 SwiftUI 布局系统的核心:HStack/VStack/ZStack 三种 Stack、Spacer 与 Divider 工具、padding 与 frame 的使用方式、LazyVGrid/LazyHGrid 网格布局、ScrollView 滚动容器、GeometryReader 动态尺寸获取,以及安全区域处理。下一章进入 SwiftUI 最重要的主题——状态管理。