LazyVGrid & LazyHGrid

List 视图是显示滚动数据行的好方法,但有时还需要数据列,能够在大屏上显示更多数据。这时就要用到 LazyHGridLazyVGrid


1. 用 GridItem 定义布局

定义网格布局有以下几种常用方法:

// 1.创建了3列网格,并让每一列具备精确宽度
let columns = [
    GridItem(.fixed(80)),
    GridItem(.fixed(80)),
    GridItem(.fixed(80))
]

// 2.控制每个单元格最小宽度,在此基础上网格在每行中容纳尽可能多的项目,所以列数不定,只写一个即可
let columns = [
    GridItem(.adaptive(minimum: 80))
]

// 3.使用 .flexible 精确控制列数(4列,那里面的单元格大小可以自适应)
let columns = [
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible()),
    GridItem(.flexible())
]

// 还可以设置间隔
let columns = [
    GridItem(.flexible(), spacing: 15),
    GridItem(.flexible())
]

// 4.结合起来使用:这将使第一列的宽度恰好为 100,第二列则会填充所有剩余空间
let columns = [
    GridItem(.fixed(100)),
    GridItem(.flexible()),
]

<aside> 💡 .flexible 用于自动调整列的宽度的大小,而 .adaptive 用于根据可用空间动态调整列的数量。根据具体的布局需求,你可以选择使用这两个选项中的一个来定义网格布局中的列。

</aside>

Screenshot - 2025-02-13 22.19.27.png

Screenshot - 2025-02-13 22.20.43.png

2. 用 LazyV/HGrid 创建视图

定义好布局后,应该将网格以及所需数量的项目放置在 ScrollView 内。您在网格中创建的每个项目都会自动分配一列,就像列表中的行自动放置在其父项中一样。我们只定义 LazyHGridLazyVGrid 两者中的一个,具体取决于想要哪种类型的网格。

// 定义垂直网格 LazyVGrid 时,参数是 columns
LazyVGrid(columns: layout, spacing: 20) {
		ForEach(0..<100) {
				Text("Item \\($0)")
		}
}

// 定义水平网格 LazyHGrid 时,参数是 rows
LazyHGrid(rows: layout, alignment: .center) {
		ForEach(0..<100) {
				Text("Item \\($0)")
		}
}

3. 放入滚动视图

ScrollView {
    LazyVGrid(columns: layout) {
        ForEach(0..<1000) {
            Text("Item \\($0)")
        }
    }
}

制作水平网格的过程几乎相同,您只需使 ScrollView 水平工作,然后使用行而不是列创建 LazyHGrid

ScrollView(.horizontal) {
    LazyHGrid(rows: layout) {
        ForEach(0..<1000) {
            Text("Item \\($0)")
        }
    }
}

静态网格 Grid & GridRow

Grid 视图可以创建静态视图网格,并精确控制每行和每列的内容。您可以使用 GridRow 标记各个行,然后还可以配置每个单元格的宽度。

// 作为基本示例,这将创建一个 2x2 网格,其中的文本反映每个单元格的位置:
Grid {
    GridRow {
        Text("Top Leading")
            .background(.red)

        Text("Top Trailing")
            .background(.orange)
    }

    GridRow {
        Text("Bottom Leading")
            .background(.green)

        Text("Bottom Trailing")
            .background(.blue)
    }
}

如果您不想每行具有相同数量的单元格,有3种选择:

// 第1种方法:什么都不做,SwiftUI 将自动插入空单元格以确保 rows 相等
struct ContentView: View {
    @State private var redScore = 0
    @State private var blueScore = 0
    var body: some View {
        Grid {
            GridRow {
                Text("Red")
                ForEach(0..<redScore, id: \\.self) { _ in
                    Rectangle()
                        .fill(.red)
                        .frame(width: 20, height: 20)
                }
            }

            GridRow {
                Text("Blue")
                ForEach(0..<blueScore, id: \\.self) { _ in
                    Rectangle()
                        .fill(.blue)
                        .frame(width: 20, height: 20)
                }
            }
        }
        .font(.title)
        Button("Add to Red") { redScore += 1 }
        Button("Add to Blue") { blueScore += 1 }
    }
}

// 第2个方法:是将视图放入网格中,而不将它们包装在 GridRow 中,这将导致它们自己占据整行。这对于 Divider 视图非常有用
// 第3个方法:是使用 gridCellColumns() 修饰符,使一个单元格跨越多个列
Grid {
    GridRow {
        Text("Food")
        Text("$200")
    }

    GridRow {
        Text("Rent")
        Text("$800")
    }

    Divider()

    GridRow {
        Text("$4600")
            .gridCellColumns(2)
            .multilineTextAlignment(.trailing)
    }

}
.font(.title)

图片撑破网格的 Bug

用 swiftUI 实现一个两列的网格视图,每个单元格的宽度是自适应的,也就是说排除额外的 padding 后,单元格的宽度应该是占屏幕一半。其次每个单元格的高度是固定的,例如 180 。然后每个单元格里面会展示一个尺寸不确定的 Image,希望 Image 是填充满整个单元格区域,同时等比例缩放的,溢出部分就裁切掉。

核心思路很清晰:

// MARK: - 单元格视图代码示例
struct PhotoGridCell: View {
    let imageName: String
    var body: some View {
        Image(imageName)
            .resizable()
            .scaledToFill()
            .frame(maxWidth: .infinity, maxHeight: 180)
            .clipped()
            .cornerRadius(10)
    }
}

但是这个代码发现不管怎么改,横版的图片都会撑破单元格,并且溢出的部分没有裁掉。

这是 SwiftUI 布局系统的一个经典陷阱。问题出在修饰符的执行顺序上:scaledToFill() 会先让图片按比例放大,然后向父级上报它"理想"的尺寸(对横版图来说宽度很大),LazyVGrid 接收到这个尺寸后就把单元格撑开了,.clipped() 只裁剪了视觉渲染,但没有阻止布局扩张。

解决方案:让一个固定尺寸的容器先占据布局空间,再用 overlay 把图片填进去,这样布局系统看到的永远是容器的尺寸,而不是图片的理想尺寸。核心原理:

struct PhotoGridCell: View {
    let imageName: String

    var body: some View {
        Rectangle()
            .fill(Color.clear)
            .frame(maxWidth: .infinity, maxHeight: 180)
            .overlay {
                Image(imageName)
                    .resizable()
                    .scaledToFill()
            }
            .clipped()
            .cornerRadius(10)
    }
}

好问题,这涉及 SwiftUI 布局系统一个很核心的概念区别。SwiftUI 布局每次都经历这几步:1. 基本原理

1. 父视图 → 向子视图"提议"一个尺寸(Propose)
2. 子视图 → 向父视图"上报"自己需要的尺寸(Report back)
3. 父视图 → 决定把子视图放在哪里

父视图最终的尺寸,取决于子视图上报的尺寸,而不是父视图自己的提议。

max 参数 vs 固定值,行为完全不同。这是你困惑的核心。frame 有两种用法,语义截然不同:

固定尺寸:这个 frame 会忽略子视图上报的尺寸,强制向父级声明"我就是 100×180"。子视图内容再大,父级看到的永远是 100×180。

.frame(width: 100, height: 180)

弹性尺寸maxWidth: .infinity 的意思是"我可以扩展到无限宽",它是一个上限约束,不是一个固定值。这个 frame 会把子视图上报的尺寸透传给父级(在约束范围内)。

.frame(maxWidth: .infinity, maxHeight: 180)

之前代码实际发生了什么?

高度方向没问题,因为 maxHeight: 180 把 180 以上的高度截住了。但宽度方向,maxWidth: .infinity 对 480 毫无约束,LazyVGrid 收到"我需要 480 宽",于是把列撑开了。

Image(imageName)
    .resizable()
    .scaledToFill()         // ← 横版图:上报宽度 = 480(举例)
    .frame(maxWidth: .infinity, maxHeight: 180)  // ← maxWidth 是无限,480 没超限,原样透传
    .clipped()              // ← 只管视觉裁切,不影响布局尺寸

overlay 方案为什么有效?

overlay 里的内容是脱离布局流的——它只做渲染,不向父级上报尺寸。所以 LazyVGrid 收到的尺寸始终来自 Rectangle,图片的理想宽度对它来说是透明的。

Rectangle()
    .frame(maxWidth: .infinity, maxHeight: 180)  // ← Rectangle 本身没有内容尺寸,它就老老实实按列宽上报
    .overlay {
        Image(...)
            .resizable()
            .scaledToFill()  // ← 图片在这里再大,也不参与布局计算
    }
    .clipped()

<aside> 💡

</aside>