凌晨三点,楼里只剩空调低鸣。林屿坐在工位前,盯着
SwiftUI里的List,像盯着一个多年的老朋友。这个老朋友不坏,甚至称得上可靠。可今天,他忽然觉得不对劲了。页面能跑,交互也顺,但那层说不清的“高级感”,总像隔着一层雾,伸手能碰到,握住却没有。问题出在哪?他顺着代码往下摸,摸到最后,才发现真正的悬念从来不在样式,而在工具选错了场子。
🧭 在 SwiftUI 中构建 List 的替代方案
每当你打算在 SwiftUI 里做一个可滚动页面时,第一反应往往是用 List。这很正常。List 名声在外,又是系统组件,出手就带着几分正统气息。
但话说回来,它并不总是最合适的选择。
List 最擅长的,是展示那种整齐划一的统一数据。比如邮箱列表、待办事项、联系人,这类内容结构规整,节奏统一,List 处理起来得心应手。
可如果不是这种场景,画风就变了。
对于其他更灵活、更讲究布局和视觉层次的页面,ScrollView 搭配 lazy stack,几乎总是更好的方案。
这篇文章要讲的,就是如何在 SwiftUI 中构建一个自定义的可滚动容器,让我们对 look and feel 拥有真正精细、可控的掌握力。
⚙️ 先把底牌摊开:ScrollView 这几年,已经不是昨日黄花
先说一句实在话。
过去几年里,SwiftUI 对 ScrollView + lazy stacks 的性能做了相当大的改进。它早已不是那个“能用,但心里发毛”的角色。今天的它,已经足够稳,足够快,也足够灵活。
所以,如果你展示的不是那种几十万条统一数据,比如邮箱、待办清单这种超大规模列表,那么:
ScrollViewis a way to go.
这句话轻描淡写,实际上意味深长。
它的意思不是 “List 不行”,而是:
如果你的页面不依赖大规模统一数据的复用机制,那你就没必要把自己绑死在 List 上。
工具有长处,也有边界。看不见边界,迟早吃亏。
🫀 CardioBot 的现状:已经不错,但还不够狠
这是林屿自己独立开发的 CardioBot app。
上面有 4 张截图:前两张是当前版本,后两张是林屿想达到的效果。
现在这款 app 使用的是标准 List。而且说句公道话,它现在的界面观感并不差,作者自己也很喜欢它目前的 look and feel。
但人一旦开始较真,就回不了头。
林屿决定重新审视自己的 UI。目标并不激进,不是要把界面改得面目全非,而是要做到两件事:
- 保留 iPhone 用户熟悉、直观、可识别的感觉
- 让 UI 再精致一些,再讲究一些,再“骚”一点,但绝不轻浮
这类优化最难。它不是“重做”,而是“进一寸”。可往往,真正拉开差距的,就是这一寸。
🧱 为什么这里的 List 已经不再对味了
CardioBot 展示的是不同类型的健康指标。问题在于,这些内容不是统一数据集,而是一组风格不同、职责不同的内容块。
林屿用了多种 card 类型,比如:
HeroCardTintedCardRegularCard
看到这里,症结就露出来了。
如果数据并不统一,那么使用 List 去做 cell recycling,其实就没多大意义。List 的一身本事,主要是为海量、统一、标准化的数据而生。可这里是一桌散席,不是整齐列队。
林屿当然也试过继续依赖 List。
他可以通过一些 list-specific view modifiers 做出接近目标的样子,比如:
listRowBackgroundlistItemTintlistRowInsets
它们在 List 内部确实很好使,像一把趁手的短刀。
可惜,刀再锋利,也有出鞘范围。
这些 list-specific view modifiers 一旦离开 List view,立刻失灵。也就是说,这些能力是 List 私有的,不可外借。
结果就是:
你想在 List 之外维持相同风格,就必须额外补样式。补来补去,补成了拆东墙补西墙,最后不是代码发虚,就是视觉跑偏。
这就不是“能不能做”的问题了,而是“做得值不值”。
🪄 真正的转机:Container View APIs
幸运的是,SwiftUI 后来引入了 Container View APIs。
这套 API 看起来安安静静,实际上杀伤力很大。它允许我们把 SwiftUI 视图先拆解,改点东西,再重新组合回来。
这意味着什么?
意味着你不再只是“使用容器”,而是可以“制造容器”。
你可以借助 Container View APIs 构建可复用的容器视图,像 List、Form,甚至任何高度自定义的东西。
说穿了,这是一种权限的变化。
以前你在用系统给的积木。
现在你很Happy的开始自己烧砖。
📦 第一块积木:ScrollingSurface
由于林屿的 app 中每个页面都采用 ScrollView 加 lazy stack,所以他提炼出了一个统一类型:ScrollingSurface。
public struct ScrollingSurface<Content: View>: View {
public enum Direction {
case vertical(HorizontalAlignment)
case horizontal(VerticalAlignment)
}
let direction: Direction
let spacing: CGFloat?
let content: Content
public init(
_ direction: Direction = .vertical(.leading),
spacing: CGFloat? = nil,
@ViewBuilder content: () -> Content
) {
self.spacing = spacing
self.direction = direction
self.content = content()
}
public var body: some View {
switch direction {
case .horizontal(let alignment):
ScrollView(.horizontal) {
LazyHStack(alignment: alignment, spacing: spacing) {
content
}
.scrollTargetLayout() // 告诉滚动系统:这里是目标布局区域
.padding()
}
case .vertical(let alignment):
ScrollView(.vertical) {
LazyVStack(alignment: alignment, spacing: spacing) {
content
}
.scrollTargetLayout() // 垂直方向同理
.padding()
}
}
}
}
他的意思很直接:
ScrollingSurface 本质上就是对 ScrollView 和 LazyVStack / LazyHStack 的一个简单包装。根据方向不同,切换成垂直或水平滚动容器。
但别小看这个“简单”。
为什么它值得单独抽出来?
因为它做了三件很重要的事:
- 统一了页面根结构
- 统一了滚动方向的表达方式
- 统一了 spacing 和 padding 的布局语义
林屿会把 ScrollingSurface 作为 app 每个页面的 root view。
这不是偷懒,这是定规矩。
规矩一旦立住,后面的样式和结构才能不乱套。
🃏 第二块核心积木:DividedCard
接下来,UI 里的关键原语出现了:DividedCard。
它最重要的地方,在于使用了 Group(subviews:),这是 SwiftUI Container View API 的一部分。
public struct DividedCard<Content: View>: View {
let content: Content
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
Group(subviews: content) { subviews in
if !subviews.isEmpty {
VStack(alignment: .leading) {
ForEach(subviews) { subview in
subview
if subviews.last?.id != subview.id {
Divider()
.padding(.vertical, 8) // 在每个子视图之间插入分隔线
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(
.regularMaterial,
in: RoundedRectangle(cornerRadius: 32) // 给整体包上圆角卡片背景
)
}
}
}
}
Group(subviews:) 到底妙在哪?
这招很关键。
它允许我们把通过 @ViewBuilder 传进来的视图拆成一个个子视图。
换句话说,你不再只能把一整坨内容当黑盒来用,而是能看到里面每个子项,并逐个处理它们。
林屿在 DividedCard 里干的事情很漂亮:
- 先把内容拆开
- 遍历所有
subviews - 在每个子视图后面加上
Divider,但最后一个不加 - 最后把整个结构包进一个带圆角的材质背景里
结果就是:
一组原本只是“连续排列的内容”,立刻拥有了卡片感、分组感和边界感。
这一手为什么重要?
因为很多产品界面都存在这样的结构:
- 一张卡片里放多个入口
- 每个入口既独立,又需要视觉连续
- 中间要有分隔,但不能显得生硬
以前你可能要在每个地方重复写 Divider、padding、background、cornerRadius`,写多了就腻,改起来更烦。
现在不同了。
DividedCard 把这套规则提炼成了一个可复用 primitive。
这就是架构的味道:
不是“这页看着对”,而是“以后都能对”。
🧩 第三块积木:SectionedSurface
另一个很有意思的 UI primitive,是 SectionedSurface。
public struct SectionedSurface<Content: View>: View {
let content: Content
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
ForEach(sections: content) { section in
if !section.content.isEmpty {
section.header.padding(.top) // 给 section 的 header 增加顶部间距
section.content
section.footer
}
}
}
}
它使用了 ForEach(sections:),这个能力可以从传入的视图中提取所有的 Section,然后做统一处理。
林屿这里做了两件事:
- 过滤掉没有内容的 section
- 给 section header 增加一些顶部间距
这看着朴素,实际上很实用。
因为在真实业务里,section 常常是动态的。
某块有数据,就该显示;没数据,就该消失。
如果每个页面都自己处理一遍这些逻辑,迟早会写成一锅粥。
而 SectionedSurface 把这类规则直接吸收到了容器层。
页面只负责描述内容,容器负责决定组织方式。
这就叫分寸。
代码里有分寸,界面就不会失态。
➡️ 离开 List 后,NavigationLink 的箭头去哪了?
很多人一旦不用 List,很快就会发现少了点什么。
没错,就是 NavigationLink 右侧那个熟悉的小箭头,也就是 chevron。
在 List 中,它会自动出现在 trailing edge,系统帮你安排得明明白白。可一旦离开 List,这个默认样式就没了。
林屿的办法很干脆:写一个自定义 ButtonStyle。
public struct NavigationButtonStyle: ButtonStyle {
public func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.label
.opacity(configuration.isPressed ? 0.7 : 1) // 按下时微微变淡,增加反馈感
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary) // 补回 List 风格的右侧箭头
}
.contentShape(.rect) // 扩大点击区域,让整行都可点
}
}
extension ButtonStyle where Self == NavigationButtonStyle {
public static var navigation: Self { .init() }
}
这一招的好处在于,它不是临时补救,而是顺势把“导航型按钮”的风格单独抽了出来。
以后只要写:
.buttonStyle(.navigation)
整页涉及导航的按钮,就能统一表现。
这才像回事。
高手不是把洞补上,高手是顺手把墙也砌直。
🏗️ 实战拼装:SummaryView
下面这段代码,展示了前面这些新原语在 app 中的实际用法。
public struct SummaryView: View {
let summary: SummaryStore
public var body: some View {
ScrollingSurface {
SectionedSurface {
coachSection
activitySection
recoverySection
vitalsSection
heartRateSection
alcoholicBeveragesSection
}
}
.buttonStyle(.navigation) // 统一套用导航按钮样式
}
@ViewBuilder private var activitySection: some View {
Section {
if !summary.metrics.workouts.isEmpty {
DividedCard {
ForEach(summary.metrics.workouts, id: \.workout.uuid) { snapshot in
NavigationLink {
WorkoutDetailsView(snapshot: snapshot)
} label: {
WorkoutView(snapshot: snapshot)
}
}
}
}
} header: {
SectionHeader(
.horizontal,
title: Text("activitySection"),
systemImage: "figure.run"
)
.tint(.orange)
}
}
}
这一段真正漂亮的地方在哪?
表面上看,它的使用方式和 List API 非常像:
- 有
Section - 有
NavigationLink - 有 header
- 有内容分组
但底层已经换了天地。
林屿通过:
ScrollingSurfaceDividedCardSectionedSurfaceNavigationButtonStyle
重新拼出了类似 List 的使用体验,同时拿回了对 look and feel 的精准控制。
更妙的是,如果某个页面压根不需要 sections,只要把 SectionedSurface 去掉即可,其余 primitive 仍然能继续复用。
这就说明它们不是页面特供,而是真正的可复用 building blocks。
到了这一步,已经不是“替代 List”那么简单了。
这是在搭自己的界面语言。
真相大白:弃用 List 非叛逆,懂了取舍是清醒
最后,林屿把话说得很准。
在 SwiftUI 里替换 List,并不是要放弃一个强大的组件。它真正要表达的是:
不是背叛
List,而是为场景选择正确的工具。
如果你面对的是大型、统一的数据集,List 依旧是极好的选择,毫无问题。
但当你的 UI 需要更细致的结构、更独特的样式、更符合产品自身 design language 的表达时,现代 SwiftUI 已经给了我们足够的自由。
借助 ScrollView、lazy stacks 和 Container View APIs,我们不只是可以重建 List 的能力,某些时候甚至能够超越它。
像 ScrollingSurface、DividedCard、SectionedSurface 这样的自定义 primitive,证明了一件事:
真正成熟的 SwiftUI 代码,不只是把视图摆出来,而是把可复用的规则提炼出来。
性能、清晰度、设计语言,三者并行不悖。
这才是正路。
🌒 尾声:他最终没有推翻 List,只是看透了它
天快亮的时候,林屿合上电脑,办公室的灯光仍旧冷,心里却亮了。
他没有把 List 当成敌人。
也没有为了“自定义”而自定义。
他只是终于明白:
组件从来不是信仰,它只是工具。
该用 List 的时候,别拧巴;
该用 ScrollView 和自定义容器的时候,也别手软。
很多人写 UI,写到最后,写成了对系统组件的依赖。
可真正厉害的人,写到最后,会慢慢长出自己的容器、自己的规则、自己的语言。
那一刻,所谓 List replacement,其实已经不重要了。
重要的是,他终于从“会用组件”,走到了“会造秩序”。
而这,才是这篇文章最狠的一刀。