SwiftUI里的网格布局一直有个尴尬:LazyVGrid很擅长等宽等高的卡片列表,但你如果想做高低错落的瀑布流,它并不会自动帮你把每列内容独立排布。很多人一开始会尝试给每个格子设置不同高度,结果发现同一行的格子都被拉伸到行高最大值,图片比例被破坏,间距也乱掉。这背后的原因在于LazyVGrid本质上是按行计算布局的,每一行的高度由该行最高子视图决定,同行其他子视图默认填满该高度。要解决这个问题,要么放弃LazyVGrid改用多列HStack拼装,要么对数据进行预分组,把不同数量的条目手动塞进不同列。

下面我们先从最基础的网格自适应说起,把GridItem的几种模式理清楚,再进入瀑布流和分页加载的具体实现。
一、LazyVGrid的自适应列到底怎么配置
SwiftUI中的网格容器需要依赖GridItem数组来定义列。常见的写法有三种:固定列宽、弹性列宽和自适应列宽。其中自适应列宽通过GridItem(.adaptive(minimum: 100))实现,系统会根据可用宽度自动计算最多能放下多少列,并且每列宽度至少为指定的最小值。这种方式最适合做响应式网格,比如横竖屏切换时列数自动增减,不需要开发者手动计算。
但要特别注意,.adaptive和.flexible看起来都能填满空间,行为却不同。.flexible会把你指定的列数按权重均分剩余空间,列数是固定的;.adaptive则是列数不固定,根据最小宽度动态决定。如果你的数据是图片卡片,宽度变化对布局影响不大,用adaptive最快。但如果要做瀑布流,adaptive会带来致命问题:同一行的所有格子仍然受行高约束,高度不一致时照样会拉伸。
import SwiftUI
struct AdaptiveGridView: View {
// 模拟数据
private let items = Array(1...20).map { "卡片 \($0)" }
// 三列弹性布局,每列权重相同
private let flexibleColumns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
// 自适应布局,最小宽度120,列数自动计算
private let adaptiveColumns = [
GridItem(.adaptive(minimum: 120), spacing: 12)
]
var body: some View {
ScrollView {
// 使用自适应列
LazyVGrid(columns: adaptiveColumns, spacing: 12) {
ForEach(items, id: \.self) { item in
Text(item)
.frame(maxWidth: .infinity)
.frame(height: 80)
.background(Color.blue.opacity(0.2))
.cornerRadius(8)
}
}
.padding()
}
}
}
这段代码会在不同屏幕宽度下自动展示2列、3列甚至更多列。注意ForEach里的\.self写法,在SwiftUI中如果数据模型没有实现Identifiable,就需要给ForEach指定id路径。虽然这里的字符串数组也可以直接用id: \.self,但正式项目中建议让模型遵循Identifiable,避免重复元素导致视图复用异常。
自适应网格的另一个隐藏问题是性能。虽然名字里有Lazy,但只有LazyVGrid、LazyVStack这类容器才具备懒加载特性,普通的VGrid并不存在。使用LazyVGrid时,只有出现在屏幕上的单元格才会被创建,滚动离开后会被回收。因此如果你的网格数据量很大,千万不能把LazyVGrid嵌套在另一个垂直滚动容器内部,否则懒加载机制会失效,内存占用会急剧上升。
二、瀑布流布局的两条实现路线
瀑布流的核心诉求是每列独立排列,短卡片下面紧跟下一个卡片,而不是像网格那样要求同一行对齐。SwiftUI没有原生的瀑布流容器,但可以用两种方案模拟:一是手动维护多个VStack放进HStack中,自己分配数据到不同列;二是继续使用LazyVGrid,但通过预计算每列的数据分组,强制让每个格子只出现在指定列中,并配合固定高度避免拉伸。
第一种方案最直观。假设我们要做两列瀑布流,就创建两个VStack,把数据按奇偶索引或按高度估算分到左右两列,每个VStack内部用ForEach渲染对应卡片。这种方案的优点是完全控制每一列的高度,不会出现拉伸;缺点是需要自己处理数据分配逻辑,而且两列之间的滚动是联动的,因为外层是同一个ScrollView,所以体验没问题。代码实现如下:
import SwiftUI
struct WaterfallView: View {
// 模拟图片数据,heights用于展示不同高度
private let data = (1...30).map { index in
(title: "图片 \(index)", height: CGFloat.random(in: 120...220))
}
var body: some View {
ScrollView {
HStack(alignment: .top, spacing: 12) {
// 左列
LazyVStack(spacing: 12) {
ForEach(data.indices.filter { $0 % 2 == 0 }, id: \.self) { index in
CardView(item: data[index])
}
}
// 右列
LazyVStack(spacing: 12) {
ForEach(data.indices.filter { $0 % 2 != 0 }, id: \.self) { index in
CardView(item: data[index])
}
}
}
.padding()
}
}
}
struct CardView: View {
let item: (title: String, height: CGFloat)
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Rectangle()
.fill(
LinearGradient(
colors: [.blue.opacity(0.5), .purple.opacity(0.5)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(height: item.height)
.overlay(
Text(item.title)
.foregroundColor(.white)
.bold()
)
.cornerRadius(10)
Text("描述文字")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(10)
.background(Color(.systemBackground))
.cornerRadius(14)
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
}
}
注意这里用了LazyVStack而不是普通VStack,因为每列可能有几十上百个条目,用LazyVStack才能保证滚动性能。另外alignment: .top让两列从顶部对齐,避免其中一列较短时被垂直居中。数据分配这里用了奇偶索引拆分,简单直接,但如果每个卡片高度差异特别大,可能出现两列总高度严重不均。更合理的做法是根据当前累计高度动态选择较矮的一列插入,不过这会增加额外的状态管理,可以根据实际项目需求取舍。
第二种使用LazyVGrid做瀑布流的思路,需要提前把数据切分成若干组,每组对应一行。例如两列布局就每两个元素一组,三列布局就每三个一组。但问题在于同一行内的两个格子高度仍然会取最大值,所以你需要给每个格子设置固定高度,而这个固定高度就是该行中两个卡片高度的较大值。这样做会让较矮的卡片下方留白,整体看起来仍有网格感,并非真正的瀑布流。因此除非你有特殊理由必须使用LazyVGrid,否则更推荐第一条HStack加VStack的路线。
三、分页加载与滚动到底部的监听
分页加载是内容流产品的常见需求。SwiftUI中实现触底加载有很多方式,最简单的就是利用onAppear给列表末尾的占位视图绑定加载逻辑。当占位视图出现在屏幕上时,说明用户已经滚动到接近底部,此时触发下一页请求。这种方案不需要监听滚动偏移,代码量少,适合大多数场景。
基本思路是在ForEach渲染完数据后,追加一个底部视图,并在该视图的onAppear中调用加载函数。为了区分首次加载和后续分页,需要维护当前页码和是否还有更多数据的状态。同时要防止重复触发,可以加一个isLoading标志。下面是一个基于HStack瀑布流加上分页的完整示例:
import SwiftUI
struct PagedWaterfallView: View {
@State private var items: [WaterfallItem] = []
@State private var currentPage = 1
@State private var isLoading = false
@State private var hasMoreData = true
var body: some View {
ScrollView {
HStack(alignment: .top, spacing: 12) {
LazyVStack(spacing: 12) {
ForEach(items.indices.filter { $0 % 2 == 0 }, id: \.self) { index in
WaterfallCard(item: items[index])
}
}
LazyVStack(spacing: 12) {
ForEach(items.indices.filter { $0 % 2 != 0 }, id: \.self) { index in
WaterfallCard(item: items[index])
}
}
}
.padding()
// 底部加载指示器,onAppear触发分页
HStack {
if isLoading {
ProgressView()
.padding(.vertical, 20)
} else if !hasMoreData {
Text("没有更多内容了")
.font(.footnote)
.foregroundColor(.secondary)
.padding(.vertical, 20)
} else {
Color.clear
.frame(height: 40)
.onAppear {
loadNextPage()
}
}
}
}
.onAppear {
loadNextPage()
}
}
private func loadNextPage() {
guard !isLoading, hasMoreData else { return }
isLoading = true
// 模拟网络请求延迟
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
let newItems = (1...10).map { offset in
let globalIndex = (currentPage - 1) * 10 + offset
return WaterfallItem(
title: "内容 \(globalIndex)",
height: CGFloat.random(in: 120...220)
)
}
items.append(contentsOf: newItems)
currentPage += 1
// 模拟第3页后没有数据
if currentPage > 3 {
hasMoreData = false
}
isLoading = false
}
}
}
struct WaterfallItem {
let title: String
let height: CGFloat
}
struct WaterfallCard: View {
let item: WaterfallItem
var body: some View {
VStack(alignment: .leading, spacing: 6) {
RoundedRectangle(cornerRadius: 10)
.fill(
LinearGradient(
colors: [.teal.opacity(0.6), .indigo.opacity(0.6)],
startPoint: .top,
endPoint: .bottom
)
)
.frame(height: item.height)
.overlay(
Text(item.title)
.foregroundColor(.white)
.bold()
)
Text("文字描述区域")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(8)
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(color: .black.opacity(0.06), radius: 3, y: 1)
}
}
上面代码中的转义>是因为在代码块内部,大于号必须写成>,否则会破坏HTML结构。实际写Swift文件时这里就是普通的>。分页逻辑有几处值得注意:loadNextPage首先检查isLoading和hasMoreData,避免滚动触发重复请求;items.indices.filter每次渲染都会重新计算,数据量不大时可以接受,但数据很多时可以考虑预先拆分成两个数组,减少滚动过程中的过滤开销。
如果你需要更精确地监听滚动位置,比如距离底部还有一定距离时就开始预加载,可以使用GeometryReader配合coordinateSpace获取ScrollView的偏移量。不过这种写法代码量更多,而且需要处理坐标空间命名、内容高度和视口高度的计算。iOS 17之后,SwiftUI原生支持了scrollPosition,可以更简单地获取滚动位置,但考虑到兼容性,目前onAppear方案仍然是最稳妥的选择。
四、性能优化与布局细节中的坑
瀑布流布局最容易出现的问题不是布局写不出来,而是滚动卡顿和内存增长。卡顿的主要来源有两个:一是每列使用了普通VStack而不是LazyVStack,导致所有卡片一次性创建;二是卡片内部有复杂的阴影、模糊或渐变,而滚动时这些效果会反复重绘。针对第一点,务必使用LazyVStack;针对第二点,可以尝试减少阴影半径、避免大面积模糊,或者将渐变结果缓存为图片使用。
另一个常见的坑是图片加载。瀑布流卡片通常会包含网络图片,如果直接用AsyncImage并设置不同高度,加载过程中图片尺寸未知,会导致布局抖动。解决办法是让后端返回图片的宽高比例,前端根据比例提前计算占位高度,图片加载完成后填充。也可以使用第三方图片加载框架提供的占位图机制。如果图片高度无法预知,可以先用固定占位高度,等图片下载完成后再通过动画调整高度,虽然仍会抖动,但至少不会打乱整个布局。
还有一点是关于HStack内部两个LazyVStack的滚动协调。因为外层是一个ScrollView,两列会一起滚动,这自然是瀑布流想要的效果。但如果某列某个卡片高度特别高,另一列会在旁边留下大片空白,视觉上可能不理想。要缓解这个问题,可以在数据分配阶段做高度均衡:每次插入新卡片前,比较两列当前累计高度,将新卡片放入较短的那一列。这个逻辑可以放进ViewModel中维护,用两个变量记录左右列累计高度,每次新增数据时更新对应列的高度值。
最后提醒一点,如果你在瀑布流中嵌入了分页加载,并且数据量增长很快,一定要在适当的时候使用id来稳定视图标识。SwiftUI的ForEach依赖id判断视图复用,如果使用索引作为id,在数据插入或删除时会导致视图状态错乱。正式项目中建议数据模型遵循Identifiable协议,使用稳定的唯一标识符,例如后端返回的ID。