导读:本期聚焦于梧桐创作的《SwiftUI ScrollView与LazyVGrid如何实现瀑布流、网格自适应与分页加载?》,敬请观看详情。如果直接用LazyVGrid做瀑布流,你会发现所有格子高度被强行拉齐,图片比例一乱,布局就崩。这篇文章不绕概念,直接拆解SwiftUI里ScrollView配合LazyVGrid搭建瀑布流、自适应网格以及触底分页加载的完整实现方案。我们会从自适应列的本质讲起,解释GridItem的flexible和adaptive到底差在哪,再给出两套瀑布流思路:一套基于HStack拼列,适合图片流;一套基于LazyVGrid改造,适合卡片流。分页部分会结合onAppear和滚动偏移监听,避免重复请求,也会说清楚iOS 17之后用scrollPosition怎么更省事。代码全部可运行,关键处有中文注释,照抄就能落地。

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

SwiftUI ScrollView与LazyVGrid如何实现瀑布流、网格自适应与分页加载?

下面我们先从最基础的网格自适应说起,把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。

SwiftUILazyVGrid瀑布流修改时间:2026-09-27 17:25:25

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0927/62636.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。