惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
The Exploit Database - CXSecurity.com
V
Vulnerabilities – Threatpost
Google DeepMind News
Google DeepMind News
Attack and Defense Labs
Attack and Defense Labs
Webroot Blog
Webroot Blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
TaoSecurity Blog
TaoSecurity Blog
I
Intezer
Application and Cybersecurity Blog
Application and Cybersecurity Blog
N
News | PayPal Newsroom
S
Security Affairs
T
Tor Project blog
P
Proofpoint News Feed
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
S
Security @ Cisco Blogs
H
Heimdal Security Blog
Hacker News: Ask HN
Hacker News: Ask HN
Help Net Security
Help Net Security
U
Unit 42
云风的 BLOG
云风的 BLOG
The Hacker News
The Hacker News
Cisco Talos Blog
Cisco Talos Blog
量子位
F
Full Disclosure
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 叶小钗
有赞技术团队
有赞技术团队
T
Troy Hunt's Blog
P
Privacy & Cybersecurity Law Blog
Forbes - Security
Forbes - Security
人人都是产品经理
人人都是产品经理
L
Lohrmann on Cybersecurity
Apple Machine Learning Research
Apple Machine Learning Research
Microsoft Security Blog
Microsoft Security Blog
博客园 - Franky
腾讯CDC
AI
AI
Last Week in AI
Last Week in AI
Latest news
Latest news
Google Online Security Blog
Google Online Security Blog
N
Netflix TechBlog - Medium
Engineering at Meta
Engineering at Meta
GbyAI
GbyAI
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
IT之家
IT之家
Martin Fowler
Martin Fowler
Blog — PlanetScale
Blog — PlanetScale
V2EX - 技术
V2EX - 技术
酷 壳 – CoolShell
酷 壳 – CoolShell

肘子的 Swift 记事本 | Fatbobman's Blog

当每一次写入都有了新价格 -- 肘子的 Swift 周报 #144 当 Linux 成为“空气”:容器、Agent 与不再重要的“桌面之争” -- 肘子的 Swift 周报 #143 两个 SwiftUI 动画 Bug 排查小记 SPI 加入 Apple,Swift 迈向自举 -- 肘子的 Swift 周报 #142 Swift 还让你 Excited 吗? -- 肘子的 Swift 周报 #141 从 Size Class 到可用空间,horizontalSizeClass 还可靠吗? WWDC 26:AI 帮你看完了,然后呢? -- 肘子的 Swift 周报 #140 WWDC 2026 初印象:符合预期,但更务实 -- 肘子的 Swift 周报 #139 Core Data + Observation:从属性级响应到心智解放 稳定 > 新功能 -- 肘子的 Swift 周报 #138 从社区路标到生态基石:Dave Verwer 的新篇章 -- 肘子的 Swift 周报 #137 消失的 WWDC 愿望单 -- 肘子的 Swift 周报 #136 CocoaPods 正在退场,SwiftPM 才刚到第二章 -- 肘子的 Swift 周报 #135 让 AI 从称手到称心 -- 肘子的 Swift 周报 #134 Swift 并发正被更广泛地接纳 -- 肘子的 Swift 周报 #133 从 OpenSwiftUI 到 DanceUI:换个方式 Dive SwiftUI -- 肘子的 Swift 周报 #132 被 Vibe 摧毁的版权壁垒,与开发者的新护城河 -- 肘子的 Swift 周报 #131 苹果的罕见妥协:当高危漏洞遇上“拒升”潮 -- 肘子的 Swift 周报 #130 一墙之隔,不同的时空 -- 肘子的 Swift 周报 #129 我的 App 审核被卡了? -- 肘子的 Swift 周报 #128 CDE:一次让 Core Data 更像现代 Swift 的尝试 50 岁的苹果和 51 岁的我 -- 肘子的 Swift 周报 #127 2026 年,为什么我仍在思考 Core Data Macbook Neo:苹果重回校园的起点 -- 肘子的 Swift 周报 #126 跨域传递 NSManagedObjectContext 为什么在 Swift 6.2 中不再报错?真正的变化不在编译器 Xcode 26.3 + Claude Agent:模型替换、MCP、Skill 与自适应配置 macOS 录屏软件开发实录:从像素抓取到元数据重现 isolated(any) 与 #isolation:让 Swift 闭包自动继承隔离域 在 tvOS 上活下來:一個非典型播放器的工程實錄 告别“可移植汇编”:我已让 Swift 在 MCU 上运行七年 独立开发者的试炼:Zipic 从 0 到 1 的产品化之路 逃离 Mac App Store:如何从零构建独立应用的分发与售卖体系 为什么父视图无法修改子视图的 @State Partytown 实战:修复 GTM 自定义事件与 dataLayer 失效问题 Swift 6 编译加速:启用 Swift-Syntax 预构建 (Prebuilts) Swift 模块化:使用 package 关键字实现 Target 间安全共享 Monorepo 实战:解决 VSCode/Cursor 中 SwiftLint 配置失效问题 VSCode/Cursor 测试 Core Data:手动编译 xcdatamodeld 指南
用自定义 Layout 化解 SwiftUI List 的行高与间距跳变
东坡肘子 · 2026-05-27 · via 肘子的 Swift 记事本 | Fatbobman's Blog

动画的声明式表达是 SwiftUI 的核心优势之一。但在某些场景里,结果并不总像我们期待的那样平滑。一个典型例子是:当 List 行内的内容高度发生动态变化——副标题从空变为非空、文本因更新而导致行数变化——系统自带的布局引擎往往无法给出连续的过渡动画,伴随而来的是肉眼可见的高度跳变、闪烁,乃至裁剪异常。本文从这个现象出发,逐层拆解原因,给出一种完全基于 SwiftUI 原生能力的解决方案;也借这条路径回看 SwiftUI 在布局机制层面的几个关键约束。

本文不会逐行解释方案中的全部细节,更多是从思路层面展开。完整代码可以在 此处 获取。

List Row 的动画异常

List 是 SwiftUI 中最常用的容器之一。但只要我们尝试让行内的内容高度发生变化,就很容易撞上一个熟悉的问题:尽管已经设置了动画,行高仍然是硬切的。

例如,一个 row 里有一段可选描述,它可能因为 iCloud 同步出现,也可能因为用户在 Sheet 中提交表单而更新。我们很自然会写出这样的代码:

VStack(alignment: .leading) {
    Text(note.name)

    if !note.description.isEmpty {
        Text(note.description)
            .font(.caption)
            .transition(.opacity)
    }
}
.animation(.smooth, value: note.description)

放在 VStackLazyVStack 里,这种写法表现通常不错。但放进 List 后,结果就常常不再理想:文字本身可以淡入淡出,cell 的高度却是硬切换的。

从上面的视频可以看到,问题不仅发生在内容的显/隐过程;当 row 的高度因为内容自身长度变化而变化时,同样会出现动画异常。

为什么 List Row 中的高度变化更容易跳变

List 并不是一个普通的 VStack。它的实现仍然紧密绑定在宿主平台的滚动列表机制上,为了保证滚动性能与视图复用,列表容器需要在合适的时机精确计算每一行的尺寸。当我们在 row 内部用 if 插入或移除一段内容时,对 row 来说,尺寸并非连续变化——而是从一个布局结果切换到了另一个布局结果。

transition 只能描述子视图自身如何插入或移除,比如淡入、移动、缩放。它并不能保证父级 List 会在旧高度和新高度之间逐帧插值。

于是就出现了一个常见错觉:我们以为自己在动画 “row 的变化”,实际只动画了 row 里的某个子视图

核心问题因此可以归结为一句:List 不会为一个动态高度的 row 自动提供高度变化的动画插值

既然 List 不会自动算,那么方向也就清楚了:手动接管高度的插值过程,让高度成为显式的动画状态

SwiftUI 自身其实为这个目标提供了几个工具:Animatable 协议、geometryGroup,它们能把动画的插值过程上提或显式化。

关于这两种方案的原理,可以参考 Animatable 协议:让 SwiftUI 动画不再“失控”SwiftUI geometryGroup() 指南:从原理到实践

面临的困难

思路看起来清晰,但落到实现里,会先撞上几道难关。

数据生命周期和视图生命周期的强绑定

在传统的命令式框架(如 UIKit)中,开发者扮演的是 “布局排程器(Layout Scheduler)” 的角色,可以精确控制事件的发生顺序:

  1. 数据改变:触发逻辑。
  2. 预计算(Pre-measurement):在后台或在当前 RunLoop 的排版前提前算好新内容的高度(例如通过 systemLayoutSizeFitting)。
  3. 提交动画(Batch Update):调用 UITableView.performBatchUpdates,明确告诉系统:“我量好了,现在请用这组高度启动动画”。

这种 “先计算,后提交” 的缓冲机制,使得动画的主导权完全掌握在开发者手中。

而在 SwiftUI 的响应式体系里,这个缓冲区域被彻底抹掉了:

  • 数据状态(State)的变化是第一公民。一旦数据发生变化,SwiftUI 的数据流会第一时间、无条件地推送到渲染树,触发重绘与排版。
  • List 在收到状态变化的瞬间就会立刻向底层请求最新的 cell 尺寸并直接进行硬性的重排。
  • 开发者根本没有机会在“状态变化后”与“List 响应前”这极短的时间夹缝里,插入一段隐式的测量和动画排程

我们需要找到一种方式,让数据生命周期与视图生命周期解耦,从而拿到对动画的精确控制权。

目标高度哪里来

在 SwiftUI 中,通过隐藏的 backgroundoverlay 来测量目标尺寸是一个常见技巧。但如果我们希望做得更通用一些——把“显示/隐藏”的内容封装成一个独立组件,不再依附于另一段必然存在的内容——就会发现:测量本身失去了挂靠点。

我们希望的 API 形态大致是这样—— AnimatedPresence 自身完成测量与动画,content 不需要依附于其它必然存在的视图(如下面例子里的 name):

VStack(alignment: .leading) {
    Text(note.name)

    AnimatedPresence(
        value: description,
        animation: animation,
        contentTransition: .opacity
    ) { detail in
        Text(verbatim: detail)
            .font(.caption)
    }
}

“压缩(Squeeze)” 带来的视觉不适

常见的折叠动画方案通常使用 .frame(height: isExpanded ? nil : 0)。在这种写法下,SwiftUI 在动画过程中会向子视图传递一个逐渐变小的高度提议(Proposed Height)。

但子视图的 intrinsic height 并不会优雅地随之缩短:文本要么被截断、要么把基线推出可视区域,整段内容看起来像是在被强行砸扁,而不是在自然收拢。这不仅观感糟糕,过程中剧烈波动的“固有高度”也会进一步扰乱布局引擎的测量。

结论很明确:即便我们能显式插值高度,也不能通过“持续给子视图传递更小的 height proposal”这条路径来实现。

解决思路与架构设计

状态解耦:用状态机接管数据生命周期

高度变化的成因有三种:

  • nil -> value
  • value -> nil
  • value -> newValue

由于数据生命周期与视图生命周期的强绑定,显/隐瞬间视图树立刻发生变化,留给高度插值的空间被压缩到 0。为此,我们引入 displayValue——一个由组件内部维护、专门负责“当前动画帧渲染什么”的副本——把数据状态与渲染状态解耦。

  • 展开value 变为有效值,立即同步给 displayValue,并执行展开。
  • 折叠value 变为 nil 时,displayValue 不立即清空。它会一直保持对旧数据的引用,直到外层的 visibleHeight 动画平滑收缩到 0 之后(利用动画 completion 回调),再彻底把 displayValue 置为 nil

value 决定目标状态,displayValue 决定当前动画帧有没有素材可画。没有这层解耦,value 一旦变成 nilif let value 内的内容就会立刻销毁,消失动画就失去了内容本体。

引入自定义 Layout: 内容自身布局跳动 / 变形

前面提到,“压缩”路径走不通——子视图的 height proposal 不能跟着动画一起变。我们需要的形态是:对父容器声明的尺寸随动画连续变化,对子视图的 place proposal 维持自然 intrinsic 尺寸

也就是说,父 row 看到的高度是 visibleHeight,从 0 到真实 intrinsic、或反之;但子内容自身从未被压缩,它始终按 intrinsic 渲染并锚在 layout 的顶部。

为此,我们构建了一个自定义布局容器 VisibleHeightLayout,把“对父容器声明的尺寸”和“对子内容实际摆放的尺寸”分离开:

sizeThatFits  -> (intrinsic.width, visibleHeight)
placeSubviews -> 用 intrinsic.width / intrinsic.height 放置 subview

带来的效果:

  • 展开时:父 row 的高度逐帧增加,内容从顶部开始逐渐露出。
  • 折叠时:父 row 的高度逐帧减少,内容从底部被裁掉。
  • 内容本身不会因为外部高度变小而重新换行、压缩或重排。

所以本质上这是一种有意的非对称:报给父容器的尺寸参与动画,传给子视图的 place proposal 维持自然。

另一个收益是:这个自定义容器顺带为目标高度的测量提供了稳定的挂靠点。子内容按 intrinsic 渲染,挂在它身上的 GeometryReader(放在 background 中)读到的就是真实 intrinsic,不会被动画中的可见高度污染。

最后再配合 .clipped(),就实现了“裁剪而非压缩”的视觉效果。

经过上述方案,我们就得到了下面这样的效果:

VStack(alignment: .leading) {
    Text(verbatim: title)
        .lineLimit(1)
        .font(.headline)

    AnimatedPresence(
        value: detail,
        animation: animation,
        contentTransition: .opacity
    ) { detail in
        Text(verbatim: detail)
            .font(.caption)
            .foregroundStyle(.secondary)
            .lineLimit(3)
    }
}

进阶挑战:Spacing 的异常

细心的读者会在前面的视频里发现:当内容收起时,最后一帧仍能看到一个明显的二段跳,动画再次出现了不流畅。另外,当我们给外层 VStack 设置 explicit spacing 时,情况会更糟——内容已经完全折叠完毕,VStack 仍然会为它保留一段 spacing。

VStack(alignment: .leading, spacing: 8) {
    ...
}

标准的 VStack(spacing: 8) 内部维护的是一套对子视图的“无条件间距承诺”。在原生 VStack 眼中,子视图是物理占位的。即使 AnimatedPresence 内部的 Layout 已经把物理高度折叠到了 0,只要该视图节点还未被正式销毁,VStack 依然会在它与相邻兄弟之间强制插入 8pt 的 spacing。这就导致在折叠动画的最后阶段,用户会看到内容已经完全收拢,两端却尴尬地残留着一段空白。

而在 spacing = nil 的场景里,视图从层次结构中被移除的那一帧,默认 spacing 又会突然消失。这种“高度先归零、间距再突变”的二段式硬跳变,同样极大破坏了动画的连贯性。

为了得到更好的效果,我们构建了一个 VStack 的复刻版本:CollapsibleSpacingVStack

CollapsibleSpacingVStack 本质上是在做一个 “知道 child presence 状态的 VStack” 。它解决的不是普通的垂直排列,而是 List Row 这类场景中——某些行内容高度会折叠到 0 时,把 spacing 也纳入动力学时间线。

第一,让 child 显式声明“我可能折叠”。

.ignoredWhenCollapsed()

CollapsibleSpacingVStack 在处理带有这条标记的子视图时,当它的高度收缩到 0,就把它当成“在 spacing 计算里不存在”——即便它仍存在于视图树中。

第二,读取 child 的折叠进度。

AnimatedPresence 通过 .collapsibleSpacingProgress(spacingProgress)0...1 的 progress 暴露给父 layout。这里有个值得注意的细节:LayoutValueKey 本身是 layout 期读取的静态属性,无法直接被动画系统插值;但 ViewModifier 可以 conform Animatable,在 withAnimation transaction 内逐帧调用 body 重写 layoutValue——从而让原本静态的 layoutValue 间接获得了 animatable 能力。

CollapsibleSpacingVStack 在每次排版周期里都会读取相邻子视图的 progress,按 min(prevScale, nextScale) * baseSpacing 计算实际间距。当某一行开始折叠时,它前后的相邻间距也会跟着它的高度按相同比例、相同动画曲线向 0 收缩;高度彻底归 0 的那一刹那,间距也恰好无缝收拢为 0

第三,给被折叠 child 隔开的两个 visible sibling 补上一份 bridge spacing。

只是按 min(prevScale, nextScale) 缩 spacing 还不够。当一个 collapsible child 夹在两个 visible sibling 中间,它两侧的 spacing 会同时被压成 0,导致折叠完成后两个原本不相邻的 sibling 完全贴合。但折叠结束后它们才是真正的相邻对,仍应享有正常的 base spacing。

为此 CollapsibleSpacingVStack 会扫描连续的折叠区段,给段前后的两个 visible sibling 之间额外补一份 baseSpacingDistance(prevVisible, nextVisible) * (1 - maxScale),并均分到 collapsed 区段的前后两侧——让中间态的 spacing 分布保持左右对称,折叠完成时新相邻对也保留正确的间距。

整套机制的核心思路是:把 spacing 的变化与高度的变化锁在同一条动画时间线上,消除 spacing 突变带来的滞后感与跳变。

现在,结合 CollapsibleSpacingVStackAnimatedPresence,我们得到了符合预期的结果:

SwiftUI 不完美,但并不表示无能为力

SwiftUI 不完美,但并不意味着无能为力。面对类似问题时,常见的退路无非两条:放弃 List 改用 ScrollView + LazyVStack,或干脆放弃动画、以硬跳代替过渡。但这两种选择本质上都是在绕开问题,而非解决问题。

本文的方案没有引入任何 UIKit,完全在 AnimatableLayout 协议LayoutValueKeyViewModifier 的组合内完成。这或许也说明了一件事:SwiftUI 真正的能力边界,往往比我们第一次碰壁时以为的要宽得多。

值得追问的是——List row 的 spacing 问题之所以棘手,根源在于 SwiftUI 的布局信息是单向流动的:父容器无法感知子视图的动画状态,子视图也无法主动通知父容器”我正在折叠”。LayoutValueKey + Animatable 的组合之所以能解开这个结,正是因为它在这条单向通道里开了一扇小窗。如果你在其他场景里也遇到了父子布局之间的状态同步问题,这个思路或许同样适用。

完整代码可在 此处 获取。