SwiftUI List 中 PhaseAnimator 引发 AttributeGraph 循环崩溃怎么办?
可以先稳定行标识,把动画提取到只接收普通值的子视图中,并在编辑期间移除动画器,保留 PhaseAnimator 的阶段逻辑。这些改动还需要验证,不能保证解决原生侧滑删除时的崩溃。SwiftUI 没有公开接口能将 PhaseAnimator 调度到独立于 List 布局的更新周期。
仅凭这段日志,还无法确认问题是 @Observable 与 PhaseAnimator 的框架缺陷,也无法认定某个系统版本已经修复。
纠正 trigger: 的使用方式
原代码使用:
.phaseAnimator([0.5, 1.0], trigger: viewModel.isOverdue)
带 trigger: 的重载会在触发值变化时运行阶段序列。isOverdue 持续为 true 不会让动画持续脉动;从 true 变为 false 也会触发一次序列。Apple 的触发式动画文档
如果需要在逾期时持续脉动,应通过条件控制动画视图是否存在,并使用不带 trigger: 的循环重载。这能纠正动画行为,但不能单独作为崩溃修复。Apple 的循环式动画文档
拆分子视图的作用
AttributeGraph: cycle detected 表示 SwiftUI 检测到了属性依赖环。日志中的属性编号不足以定位某个业务属性,崩溃发生在私有框架内部也不能直接证明依赖环来自列表布局。
将动画提取到只接收 Bool 等普通值的子视图中,可以缩小动画代码直接接触模型的范围,方便做对照排查。子视图仍属于同一棵 SwiftUI 视图树,拆分后并不会获得独立的依赖图。
示例只读取属性,没有创建 $viewModel.title 之类的绑定,因此不需要 @Bindable。通过普通存储属性持有 @Observable 对象时,SwiftUI 仍能跟踪 body 中读取的可观察属性。Apple 的 Observation 迁移说明
保留 PhaseAnimator 的改法
下面的示例假设 TaskItem 已经是项目中的 @Observable 类型。逾期且允许动画时,视图会创建循环动画器;进入编辑模式或开启“减弱动态效果”后,则显示静态图标。
import SwiftUI
private enum PulsePhase: Double, CaseIterable {
case dim = 0.5
case bright = 1.0
}
private struct OverdueIcon: View {
let isAnimating: Bool
var body: some View {
Group {
if isAnimating {
Image(systemName: "clock.fill")
.phaseAnimator(PulsePhase.allCases) { content, phase in
content.opacity(phase.rawValue)
} animation: { _ in
.easeInOut(duration: 0.6)
}
} else {
Image(systemName: "clock.fill")
}
}
.frame(width: 24, height: 24)
}
}
struct TaskRow: View {
let viewModel: TaskItem
@Environment(\.editMode) private var editMode
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var isEditing: Bool {
editMode?.wrappedValue.isEditing ?? false
}
var body: some View {
HStack {
Text(viewModel.title)
Spacer()
OverdueIcon(
isAnimating: viewModel.isOverdue
&& !isEditing
&& !reduceMotion
)
}
}
}
条件分支会直接移除动画器。退出编辑模式后,阶段序列会重新开始,不会从暂停位置继续。固定图标尺寸只是为了让两个分支占用相同的空间,无法隔离依赖图。
还要检查 ForEach 的身份是否稳定:
// 假设 TaskItem 遵循 Identifiable,且 id 在对象生命周期内保持不变。
ForEach(tasks) { task in
TaskRow(viewModel: task)
}
对于可删除、可重排的数据,每个数据项都应有保持不变的标识。避免用数组下标表示身份,也不要在 body 中生成新的 UUID。
.id(UUID()) 会在视图重新计算时不断改变身份,导致 SwiftUI 重建视图及其动画状态。这能解释闪烁;即使因此不再崩溃,也不足以证明依赖环已经修复。
编辑模式不能可靠覆盖侧滑删除
editMode 可以控制编辑状态下的动画,但无法可靠地通知所有侧滑、拖动及布局操作何时开始、何时结束。上述代码需要分别验证编辑模式删除、侧滑删除和重排,不能只测其中一种。
在 .onDelete 中设置暂停标记后立即删除数据,并不能保证动画器已经提前退出。回调执行时,侧滑交互已经发生;同一次更新中的状态修改,也不能保证按预想分成两次布局。
以下做法同样不能保证等到 List 布局完成:
DispatchQueue.main.asyncTask.yield()- 固定延迟若干毫秒
.transaction { $0.animation = nil } 可以帮助排查动画事务的影响,但不能作为停止 PhaseAnimator 或隔离依赖图的通用开关。
仍然崩溃时,保留阶段模型并替换驱动方式
如果拆分后仍能稳定复现,而项目中的 withAnimation 确实能避开问题,可以改用可取消任务,按顺序驱动各个阶段。这样可以保留明确的阶段定义,暂时停用 PhaseAnimator。
下面沿用前面的 PulsePhase:
private struct SequencedPulseIcon: View {
@State private var phase: PulsePhase = .dim
var body: some View {
Image(systemName: "clock.fill")
.opacity(phase.rawValue)
.frame(width: 24, height: 24)
.task {
phase = .dim
while !Task.isCancelled {
let next: PulsePhase =
phase == .dim ? .bright : .dim
withAnimation(.easeInOut(duration: 0.6)) {
phase = next
}
do {
try await Task.sleep(
for: .milliseconds(600)
)
} catch {
return
}
}
}
}
}
将 OverdueIcon 动画分支中的内容替换为 SequencedPulseIcon() 即可。任务随视图生命周期取消,睡眠被取消后会直接退出,避免行被移除后继续推进阶段。这里的等待时间用于编排动画,与 List 何时完成布局无关。
这个方案依据的是项目中已观察到的 withAnimation 表现,仍需在目标设备上验证,尚不能确认它适用于所有系统版本。
如何缩小问题范围
先固定数据和行标识,再分别测试静态图标、独立子视图中的 PhaseAnimator,以及任务驱动的阶段动画。每组都要检查侧滑删除、编辑模式删除和重排。
如果行身份稳定、动画闭包只读、子视图只接收普通值时仍能复现,就整理最小工程,通过 Feedback Assistant 提交完整崩溃报告、复现步骤,以及实际设备系统和 Xcode 的构建号。现有信息不足以认定题述版本存在已知缺陷,也无法承诺升级到某个版本就能解决。
备注:内容仅供参考。
