用自定义 Layout 化解 SwiftUI List 的行高与间距跳变

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

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

List Row 的动画异常

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

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

Swift
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):

Swift
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,把“对父容器声明的尺寸”和“对子内容实际摆放的尺寸”分离开:

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

带来的效果:

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

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

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

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

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

Swift
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。

Swift
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 显式声明“我可能折叠”。

Swift
.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 的组合之所以能解开这个结,正是因为它在这条单向通道里开了一扇小窗。如果你在其他场景里也遇到了父子布局之间的状态同步问题,这个思路或许同样适用。

完整代码可在 此处 获取。

订阅 Fatbobman 周报

每周精选 Swift 与 SwiftUI 开发技巧,加入众多开发者的行列。

立即订阅