axiom-swiftui-debugging by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-debuggingSwiftUI 调试分为三类,每类都有不同的诊断方法:
核心原则:从可观察的症状开始,系统地进行测试,逐一排除原因。不要猜测。
要求:Xcode 26+, iOS 17+(iOS 14-16 的模式仍然有效,参见注释) 相关技能:axiom-xcode-debugging(缓存损坏诊断)、axiom-swift-concurrency(观察者模式)、axiom-swiftui-performance(使用 Instruments 进行性能分析)、axiom-swiftui-layout(自适应布局模式)
这些是开发者提出的真实问题,本技能旨在解答:
→ 本技能将引导你通过决策树来识别是结构体突变、绑定丢失还是缺少观察者
→ 本技能展示如何使用 .environment() 或 提供缺少的依赖项
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
.environmentObject()→ 本技能识别由条件语句导致的意外视图重建,并展示 .opacity() 修复方法
→ 本技能解释何时对 @Observable 对象使用 @State 与普通属性
→ 本技能识别 ForEach 身份问题,并展示如何使用稳定的 ID
axiom-xcode-debuggingaxiom-swift-concurrencySwiftUI 提供了一个仅用于调试的方法,用于理解为何调用视图的 body。
在 LLDB 中使用:
// 在视图的 body 中设置断点
// 在 LLDB 控制台中:
(lldb) expression Self._printChanges()
在代码中临时使用(发布前移除):
var body: some View {
let _ = Self._printChanges() // 仅用于调试
Text("Hello")
}
输出解释:
MyView: @self changed
- 意味着视图值本身发生了变化(传递给视图的参数)
MyView: count changed
- 意味着 @State 属性 "count" 触发了更新
MyView: (no output)
- Body 未被调用;视图根本没有更新
⚠️ 重要:
何时使用:
交叉参考:对于复杂的更新模式,使用 SwiftUI Instrument → 参见 axiom-swiftui-performance 技能
最常见的困扰:你更改了 @State,但视图没有重绘。根本原因总是以下四件事之一。
#Preview {
YourView()
}
是 → 问题出在你的代码中。继续步骤 2。
否 → 可能是 Xcode 状态或缓存损坏。跳转到预览崩溃部分。
症状:你直接修改了一个 @State 值,但视图没有更新。
发生原因:SwiftUI 看不到对结构体的直接修改。你需要重新分配整个值。
// ❌ 错误:直接突变不会触发更新
@State var items: [String] = []
func addItem(_ item: String) {
items.append(item) // SwiftUI 看不到这个变化
}
// ✅ 正确:重新分配会触发更新
@State var items: [String] = []
func addItem(_ item: String) {
var newItems = items
newItems.append(item)
self.items = newItems // 完全重新分配
}
// ✅ 也正确:使用绑定
@State var items: [String] = []
var itemsBinding: Binding<[String]> {
Binding(
get: { items },
set: { items = $0 }
)
}
修复方法:始终重新分配整个结构体值,而不是其中的一部分。
症状:你传递了一个绑定到子视图,但子视图中的更改没有更新父视图。
发生原因:你传递了 .constant() 或每次创建新的绑定,破坏了两者之间的双向连接。
// ❌ 错误:常量绑定是只读的
@State var isOn = false
ToggleChild(value: .constant(isOn)) // 更改被忽略
// ❌ 错误:每次渲染都创建新的绑定
@State var name = ""
TextField("Name", text: Binding(
get: { name },
set: { name = $0 }
)) // 每次父视图渲染时都创建新的绑定对象
// ✅ 正确:传递实际的绑定
@State var isOn = false
ToggleChild(value: $isOn)
// ✅ 正确 (iOS 17+):对 @Observable 对象使用 @Bindable
@Observable class Book {
var title = "Sample"
var isAvailable = true
}
struct EditView: View {
@Bindable var book: Book // 启用 $book.title 语法
var body: some View {
TextField("Title", text: $book.title)
Toggle("Available", isOn: $book.isAvailable)
}
}
// ✅ 也正确 (iOS 17+):@Bindable 作为局部变量
struct ListView: View {
@State private var books = [Book(), Book()]
var body: some View {
List(books) { book in
@Bindable var book = book // 内联绑定
TextField("Title", text: $book.title)
}
}
}
// ✅ 正确 (iOS 17 之前):创建一次绑定,而不是在 body 中
@State var name = ""
@State var nameBinding: Binding<String>?
var body: some View {
if nameBinding == nil {
nameBinding = Binding(
get: { name },
set: { name = $0 }
)
}
return TextField("Name", text: nameBinding!)
}
修复方法:尽可能直接传递 $state。对于 @Observable 对象 (iOS 17+),使用 @Bindable。如果创建自定义绑定 (iOS 17 之前),在 init 中创建或缓存它们,而不是在 body 中。
症状:视图更新了,但 @State 值重置为初始状态。你看到初始值的短暂闪烁。
发生原因:视图获得了新的身份(从条件语句中移除、在容器中移动或容器本身被重建),导致 SwiftUI 将其视为新视图。
// ❌ 错误:当条件翻转时,视图身份发生变化
@State var count = 0
var body: some View {
VStack {
if showCounter {
Counter() // 每次 showCounter 改变时获得新身份
}
Button("Toggle") {
showCounter.toggle()
}
}
}
// Counter 被重建,@State count 重置为 0
// ✅ 正确:使用不透明度或隐藏来保持身份
@State var count = 0
var body: some View {
VStack {
Counter()
.opacity(showCounter ? 1 : 0)
Button("Toggle") {
showCounter.toggle()
}
}
}
// ✅ 也正确:如果必须条件显示,使用 id()
@State var count = 0
var body: some View {
VStack {
if showCounter {
Counter()
.id("counter") // 稳定的身份
}
Button("Toggle") {
showCounter.toggle()
}
}
}
修复方法:通过使用 .opacity() 代替条件语句,或应用带有稳定标识符的 .id() 来保持视图身份。
症状:对象已更改,但观察它的视图没有更新。
发生原因:SwiftUI 不知道要监视对象中的更改。
// ❌ 错误:属性更改不会触发更新
class Model {
var count = 0 // 不可观察
}
struct ContentView: View {
let model = Model() // 每次渲染都创建新实例,不可观察
var body: some View {
Text("\(model.count)")
Button("Increment") {
model.count += 1 // 视图不更新
}
}
}
// ✅ 正确 (iOS 17+):使用 @Observable 和 @State
@Observable class Model {
var count = 0 // 不需要 @Published
}
struct ContentView: View {
@State private var model = Model() // @State,不是 @StateObject
var body: some View {
Text("\(model.count)")
Button("Increment") {
model.count += 1 // 视图更新
}
}
}
// ✅ 正确 (iOS 17+):注入的 @Observable 对象
struct ContentView: View {
var model: Model // 只是一个普通属性
var body: some View {
Text("\(model.count)") // 当 count 改变时视图更新
}
}
// ✅ 正确 (iOS 17+):带有环境的 @Observable
@Observable class AppModel {
var count = 0
}
@main
struct MyApp: App {
@State private var model = AppModel()
var body: some Scene {
WindowGroup {
ContentView()
.environment(model) // 添加到环境
}
}
}
struct ContentView: View {
@Environment(AppModel.self) private var model // 从环境中读取
var body: some View {
Text("\(model.count)")
}
}
// ✅ 正确 (iOS 17 之前):使用 @StateObject/ObservableObject
class Model: ObservableObject {
@Published var count = 0
}
struct ContentView: View {
@StateObject var model = Model() // 用于拥有的实例
var body: some View {
Text("\(model.count)")
Button("Increment") {
model.count += 1 // 视图更新
}
}
}
// ✅ 正确 (iOS 17 之前):对注入的实例使用 @ObservedObject
struct ContentView: View {
@ObservedObject var model: Model // 从父视图传入
var body: some View {
Text("\(model.count)")
}
}
修复方法 (iOS 17+):在你的类上使用 @Observable 宏,然后用 @State 存储它。视图会自动跟踪它们读取的属性的依赖关系。
修复方法 (iOS 17 之前):如果你拥有该对象,使用 @StateObject;如果是注入的,使用 @ObservedObject;或者如果它在整个树中共享,使用 @EnvironmentObject。
为什么 @Observable 更好 (iOS 17+):
@Published 包装器@State 一起使用,而不是 @StateObject@ObservedObject另请参阅:在你的应用中管理模型数据
digraph view_not_updating {
start [label="View not updating?" shape=diamond];
reproduce [label="Can reproduce in preview?" shape=diamond];
cause [label="What changed?" shape=diamond];
start -> reproduce;
reproduce -> cause [label="yes: bug in code"];
reproduce -> "Cache/Xcode state → Preview Crashes" [label="no"];
cause -> "Struct Mutation" [label="modified struct directly"];
cause -> "Lost Binding Identity" [label="passed binding to child"];
cause -> "Accidental Recreation" [label="view inside conditional"];
cause -> "Missing Observer" [label="object changed, view didn't"];
}
当你的预览无法加载或立即崩溃时,三个根本原因是不同的。
根本原因:预览缺少必需的依赖项(@EnvironmentObject、@Environment、导入的模块)。
// ❌ 错误:ContentView 需要一个模型,但预览没有提供
struct ContentView: View {
@EnvironmentObject var model: AppModel
var body: some View {
Text(model.title)
}
}
#Preview {
ContentView() // 崩溃:找不到模型
}
// ✅ 正确:提供依赖项
#Preview {
ContentView()
.environmentObject(AppModel())
}
// ✅ 也正确:检查缺少的导入
// 如果使用自定义类型,请确保它们在预览文件中被导入
#Preview {
MyCustomView() // 确保 MyCustomView 已定义或导入
}
修复方法:追踪错误,找出缺少的内容,提供给预览。
根本原因:状态初始化在运行时失败。视图尝试访问不存在的数据。
// ❌ 错误:运行时数组越界
struct ListView: View {
@State var selectedIndex = 10
let items = ["a", "b", "c"]
var body: some View {
Text(items[selectedIndex]) // 崩溃:索引 10 不存在
}
}
// ❌ 错误:可选值强制解包失败
struct DetailView: View {
@State var data: Data?
var body: some View {
Text(data!.title) // 如果 data 为 nil 则崩溃
}
}
// ✅ 正确:安全默认值
struct ListView: View {
@State var selectedIndex = 0 // 有效索引
let items = ["a", "b", "c"]
var body: some View {
if selectedIndex < items.count {
Text(items[selectedIndex])
}
}
}
// ✅ 正确:处理可选值
struct DetailView: View {
@State var data: Data?
var body: some View {
if let data = data {
Text(data.title)
} else {
Text("No data")
}
}
}
修复方法:检查你的 @State 初始化器。检查数组边界、可选值解包和默认值。
根本原因:Xcode 缓存损坏。预览进程有关于你代码的陈旧信息。
诊断清单:
修复方法(按顺序):
Cmd+Option+Prm -rf ~/Library/Developer/Xcode/DerivedDataCmd+B如果完成所有四个步骤后仍然损坏:这不是缓存问题,请参见错误类型 1 或 2。
digraph preview_crashes {
start [label="Preview crashes?" shape=diamond];
error [label="Error message visible?" shape=diamond];
start -> error;
error -> "Missing Dependency" [label="'Cannot find in scope'"];
error -> "State Init Failure" [label="'Fatal error' or silent crash"];
error -> "Cache Corruption" [label="no error"];
"Cache Corruption" -> "Restart Preview → Restart Xcode → Nuke DerivedData";
}
布局问题通常在视觉上很明显。将你的症状与模式匹配。
症状:视图堆叠在一起,有些不可见。
根本原因:Z 顺序错误或你没有控制可见性。
// ❌ 错误:看不到蓝色视图
ZStack {
Rectangle().fill(.blue)
Rectangle().fill(.red)
}
// ✅ 正确:使用 zIndex 控制图层顺序
ZStack {
Rectangle().fill(.blue).zIndex(0)
Rectangle().fill(.red).zIndex(1)
}
// ✅ 也正确:隐藏而不是从层次结构中移除
ZStack {
Rectangle().fill(.blue)
Rectangle().fill(.red).opacity(0.5)
}
症状:视图很小或意外地占据整个屏幕。
根本原因:GeometryReader 将自身调整到可用空间的大小;父视图没有约束它。
// ❌ 错误:GeometryReader 扩展以填充所有可用空间
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
Button("Next") { }
}
// Text 占据所有剩余空间
// ✅ 正确:约束几何阅读器
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
.frame(height: 100)
Button("Next") { }
}
症状:内容隐藏在刘海后面,或没有使用全屏空间。
根本原因:.ignoresSafeArea() 应用到了错误的视图。
// ❌ 错误:只有背景忽略了安全区域
ZStack {
Color.blue.ignoresSafeArea()
VStack {
Text("Still respects safe area")
}
}
// ✅ 正确:容器忽略,子视图自行定位
ZStack {
Color.blue
VStack {
Text("Can now use full space")
}
}
.ignoresSafeArea()
// ✅ 也正确:有选择地忽略哪些边
ZStack {
Color.blue
VStack { ... }
}
.ignoresSafeArea(edges: .horizontal) // 仅水平方向
症状:文本被截断,按钮比文本大,尺寸行为不可预测。
根本原因:混合使用 frame()(约束)和 fixedSize()(扩展到内容大小)。
// ❌ 错误:fixedSize() 覆盖了 frame()
Text("Long text here")
.frame(width: 100)
.fixedSize() // 覆盖了 frame 约束
// ✅ 正确:使用 frame() 进行约束
Text("Long text here")
.frame(width: 100, alignment: .leading)
.lineLimit(1)
// ✅ 正确:仅使用 fixedSize() 进行自然尺寸调整
VStack(spacing: 0) {
Text("Small")
.fixedSize() // 调整到文本大小
Text("Large")
.fixedSize()
}
症状:内边距、圆角或阴影出现在错误的位置。
根本原因:以错误的顺序应用修饰符。SwiftUI 从下到上应用。
// ❌ 错误:圆角在内边距之后应用
Text("Hello")
.padding()
.cornerRadius(8) // 圆角太大
// ✅ 正确:先圆角,后内边距
Text("Hello")
.cornerRadius(8)
.padding()
// ❌ 错误:阴影在 frame 之后
Text("Hello")
.frame(width: 100)
.shadow(radius: 4) // 阴影仅在 frame 边界内
// ✅ 正确:阴影包含所有内容
Text("Hello")
.shadow(radius: 4)
.frame(width: 100)
SwiftUI 使用视图身份来跟踪视图随时间的变化、保持状态和动画过渡。理解身份对于调试状态保持和动画问题至关重要。
视图层次结构中的位置决定身份:
VStack {
Text("First") // 身份:VStack.child[0]
Text("Second") // 身份:VStack.child[1]
}
当结构身份改变时:
if showDetails {
DetailView() // 当条件改变时,身份改变
SummaryView()
} else {
SummaryView() // 相同类型,不同位置 = 不同身份
}
问题:SummaryView 每次都被重建,丢失 @State 值。
你可以使用 .id() 修饰符控制身份:
DetailView()
.id(item.id) // 显式身份与 item 绑定
// 当 item.id 改变时 → SwiftUI 视为不同的视图
// → @State 重置
// → 动画过渡
症状:@State 值在你意想不到时重置为初始值。
原因:视图身份改变(层次结构中的位置或 .id() 值改变)。
// ❌ 问题:当 showDetails 切换时,身份改变
@State private var count = 0
var body: some View {
VStack {
if showDetails {
CounterView(count: $count) // 位置改变
}
Button("Toggle") {
showDetails.toggle()
}
}
}
// ✅ 修复:使用 .opacity() 保持稳定身份
var body: some View {
VStack {
CounterView(count: $count)
.opacity(showDetails ? 1 : 0) // 始终相同的身份
Button("Toggle") {
showDetails.toggle()
}
}
}
// ✅ 也修复:显式稳定 ID
var body: some View {
VStack {
if showDetails {
CounterView(count: $count)
.id("counter") // 稳定 ID
}
Button("Toggle") {
showDetails.toggle()
}
}
}
症状:视图改变但没有动画。
原因:身份改变,SwiftUI 将其视为移除 + 添加而不是更新。
// ❌ 问题:身份随选择而改变
ForEach(items) { item in
ItemView(item: item)
.id(item.id + "-\(selectedID)") // 当选择改变时 ID 改变
}
// ✅ 修复:稳定身份
ForEach(items) { item in
ItemView(item: item, isSelected: item.id == selectedID)
.id(item.id) // 稳定 ID
}
症状:列表项跳动或动画不正确。
原因:非唯一或变化的标识符。
// ❌ 错误:基于索引的 ID 在数组改变时变化
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
Text(item.name)
}
// ❌ 错误:非唯一 ID
ForEach(items, id: \.category) { item in // 每个类别有多个项
Text(item.name)
}
// ✅ 正确:稳定、唯一的 ID
ForEach(items, id: \.id) { item in
Text(item.name)
}
// ✅ 正确:使类型符合 Identifiable
struct Item: Identifiable {
let id = UUID()
var name: String
}
ForEach(items) { item in // id: \.id 隐式
Text(item.name)
}
使用 .id() 来:
示例:数据改变时强制重建:
DetailView(item: item)
.id(item.id) // 新 item → 新视图 → @State 重置
不要使用 .id() 当:
var body: some View {
let _ = Self._printChanges()
// 检查当你未预期时是否出现 "@self changed"
}
搜索代码库中的 .id() – ID 是否意外改变?
if/else 中的视图改变位置 → 不同的身份。
修复:使用 .opacity() 或稳定的 .id() 代替。
| 症状 | 可能原因 | 修复 |
|---|---|---|
| 状态重置 | 身份改变 | 使用 .opacity() 代替 if |
| 无动画 | 身份改变 | 移除 .id() 或使用稳定 ID |
| ForEach 跳动 | 非唯一 ID | 使用唯一、稳定的 ID |
| 意外重建 | 条件位置 | 添加显式 .id() |
当你在截止日期压力下时,你会倾向于使用掩盖问题而非解决问题的快捷方式。
危险:你跳过了诊断,缓存问题在生产环境两周后重现,你需要在用户遇到崩溃时调试。
应该怎么做(5 分钟协议,总计):
Cmd+Option+P(30 秒)rm -rf ~/Library/Developer/Xcode/DerivedData(30 秒)Cmd+B(2 分钟)时间成本:5 分钟诊断 + 2 分钟修复 = 总计 7 分钟
跳过的成本:30 分钟发布 + 24 小时调试周期 = 总计 24+ 小时
危险:你在治疗症状,而不是诊断。同一视图在其他上下文中不会更新。你只是隐藏了错误。
应该怎么做(2 分钟诊断):
决策原则:如果你不能说出具体的根本原因,你还没有完成诊断。在你能够回答"问题是结构体突变,因为..."之前,不要编码。
危险:经过 2 小时的猜测后你筋疲力尽。距离 App Store 提交还有 17 小时。你感到恐慌。每一分钟都感觉紧迫,所以你停止诊断并开始胡乱尝试。
间歇性错误是最需要正确诊断的。现在一个错误的猜测就会产生一个新的错误。你发布了一个损坏的视图和一个新错误。App Store 拒绝了你。你错过了发布时间。
应该怎么做(60 分钟系统诊断):
步骤 1:在预览中复现(15 分钟)
步骤 2:隔离变量(15 分钟)
if 逻辑?移除它并测试步骤 3:应用特定的修复(30 分钟)
.opacity() 代替条件语句步骤 4:验证 100% 可靠性(直到提交)
时间成本:60 分钟诊断 + 30 分钟修复 + 信心 = 在上午 9 点提交
猜测的成本:已经 2 小时 + 再猜测 3 小时 + 引入新错误 + 发布后崩溃报告 + 紧急补丁 + 声誉损害 = 错过发布时间 + 发布后混乱
决策原则:间歇性错误需要系统诊断。在诊断中越慢,到达修复就越快。猜测是通往灾难的最快途径。
"我感谢这个建议。到处添加 @ObservedObject 是在治疗症状,而不是根本原因。技能说明间歇性错误在我们猜测时会创建新的错误。我需要 60 分钟进行系统诊断。如果到那时我还找不到根本原因,我们将禁用该功能并发布一个干净的 v1.1。数学计算表明我们有时间——我可以在截止日期前完成诊断、修复和验证。"
危险:魔法数字在其他尺寸上会失效。忽略 SafeArea 通常是错误的。锁定为 iPhone 意味着你发布了一个损坏的 iPad 体验。
应该怎么做(3 分钟诊断):
时间成本:3 分钟诊断 + 5 分钟修复 = 总计 8 分钟
魔法数字的成本:发布错误,两周后报告,调试 4 小时,在更新中打补丁 = 延迟 2+ 周
// 修复 1:重新分配整个结构体
@State var items: [String] = []
var newItems = items
newItems.append("new")
self.items = newItems
// 修复 2:正确传递绑定
@State var value = ""
ChildView(text: $value) // 传递绑定,而不是值
// 修复 3:保持视图身份
View().opacity(isVisible ? 1 : 0) // 而不是:if isVisible { View() }
// 修复 4:观察对象
@StateObject var model = MyModel()
@ObservedObject var model: MyModel
// 修复 1:提供依赖项
#Preview {
ContentView()
.environmentObject(AppModel())
}
// 修复 2:安全默认值
@State var index = 0 // 不是 10,如果数组有 3 项
// 修复 3:清除缓存
// 终端:rm -rf ~/Library/Developer/Xcode/DerivedData
// 修复 1:Z 顺序
Rectangle().zIndex(1)
// 修复 2:约束 GeometryReader
GeometryReader { geo in ... }.frame(height: 100)
// 修复 3:安全区域
ZStack { ... }.ignoresSafeArea()
// 修复 4:修饰符顺序
Text().cornerRadius(8).padding() // 先圆角
场景:你有一个任务列表。当你点击一个任务将其标记为完成时,应该出现复选标记,但没有出现。
SwiftUI debugging falls into three categories, each with a different diagnostic approach:
Core principle : Start with observable symptoms, test systematically, eliminate causes one by one. Don't guess.
Requires : Xcode 26+, iOS 17+ (iOS 14-16 patterns still valid, see notes) Related skills : axiom-xcode-debugging (cache corruption diagnosis), axiom-swift-concurrency (observer patterns), axiom-swiftui-performance (profiling with Instruments), axiom-swiftui-layout (adaptive layout patterns)
These are real questions developers ask that this skill is designed to answer:
→ The skill walks through the decision tree to identify struct mutation vs lost binding vs missing observer
→ The skill shows how to provide missing dependencies with .environment() or .environmentObject()
→ The skill identifies accidental view recreation from conditionals and shows .opacity() fix
→ The skill explains when to use @State vs plain properties with @Observable objects
→ The skill identifies ForEach identity issues and shows how to use stable IDs
axiom-xcode-debugging instead whenaxiom-swift-concurrency instead whenSwiftUI provides a debug-only method to understand why a view's body was called.
Usage in LLDB :
// Set breakpoint in view's body
// In LLDB console:
(lldb) expression Self._printChanges()
Temporary in code (remove before shipping):
var body: some View {
let _ = Self._printChanges() // Debug only
Text("Hello")
}
Output interpretation :
MyView: @self changed
- Means the view value itself changed (parameters passed to view)
MyView: count changed
- Means @State property "count" triggered the update
MyView: (no output)
- Body not being called; view not updating at all
⚠️ Important :
When to use :
Cross-reference : For complex update patterns, use SwiftUI Instrument → see axiom-swiftui-performance skill
The most common frustration: you changed @State but the view didn't redraw. The root cause is always one of four things.
#Preview {
YourView()
}
YES → The problem is in your code. Continue to Step 2.
NO → It's likely Xcode state or cache corruption. Skip to Preview Crashes section.
Symptom : You modify a @State value directly, but the view doesn't update.
Why it happens : SwiftUI doesn't see direct mutations on structs. You need to reassign the entire value.
// ❌ WRONG: Direct mutation doesn't trigger update
@State var items: [String] = []
func addItem(_ item: String) {
items.append(item) // SwiftUI doesn't see this change
}
// ✅ RIGHT: Reassignment triggers update
@State var items: [String] = []
func addItem(_ item: String) {
var newItems = items
newItems.append(item)
self.items = newItems // Full reassignment
}
// ✅ ALSO RIGHT: Use a binding
@State var items: [String] = []
var itemsBinding: Binding<[String]> {
Binding(
get: { items },
set: { items = $0 }
)
}
Fix it : Always reassign the entire struct value, not pieces of it.
Symptom : You pass a binding to a child view, but changes in the child don't update the parent.
Why it happens : You're passing .constant() or creating a new binding each time, breaking the two-way connection.
// ❌ WRONG: Constant binding is read-only
@State var isOn = false
ToggleChild(value: .constant(isOn)) // Changes ignored
// ❌ WRONG: New binding created each render
@State var name = ""
TextField("Name", text: Binding(
get: { name },
set: { name = $0 }
)) // New binding object each time parent renders
// ✅ RIGHT: Pass the actual binding
@State var isOn = false
ToggleChild(value: $isOn)
// ✅ RIGHT (iOS 17+): Use @Bindable for @Observable objects
@Observable class Book {
var title = "Sample"
var isAvailable = true
}
struct EditView: View {
@Bindable var book: Book // Enables $book.title syntax
var body: some View {
TextField("Title", text: $book.title)
Toggle("Available", isOn: $book.isAvailable)
}
}
// ✅ ALSO RIGHT (iOS 17+): @Bindable as local variable
struct ListView: View {
@State private var books = [Book(), Book()]
var body: some View {
List(books) { book in
@Bindable var book = book // Inline binding
TextField("Title", text: $book.title)
}
}
}
// ✅ RIGHT (pre-iOS 17): Create binding once, not in body
@State var name = ""
@State var nameBinding: Binding<String>?
var body: some View {
if nameBinding == nil {
nameBinding = Binding(
get: { name },
set: { name = $0 }
)
}
return TextField("Name", text: nameBinding!)
}
Fix it : Pass $state directly when possible. For @Observable objects (iOS 17+), use @Bindable. If creating custom bindings (pre-iOS 17), create them in init or cache them, not in body.
Symptom : The view updates, but @State values reset to initial state. You see brief flashes of initial values.
Why it happens : The view got a new identity (removed from a conditional, moved in a container, or the container itself was recreated), causing SwiftUI to treat it as a new view.
// ❌ WRONG: View identity changes when condition flips
@State var count = 0
var body: some View {
VStack {
if showCounter {
Counter() // Gets new identity each time showCounter changes
}
Button("Toggle") {
showCounter.toggle()
}
}
}
// Counter gets recreated, @State count resets to 0
// ✅ RIGHT: Preserve identity with opacity or hidden
@State var count = 0
var body: some View {
VStack {
Counter()
.opacity(showCounter ? 1 : 0)
Button("Toggle") {
showCounter.toggle()
}
}
}
// ✅ ALSO RIGHT: Use id() if you must conditionally show
@State var count = 0
var body: some View {
VStack {
if showCounter {
Counter()
.id("counter") // Stable identity
}
Button("Toggle") {
showCounter.toggle()
}
}
}
Fix it : Preserve view identity by using .opacity() instead of conditionals, or apply .id() with a stable identifier.
Symptom : An object changed, but views observing it didn't update.
Why it happens : SwiftUI doesn't know to watch for changes in the object.
// ❌ WRONG: Property changes don't trigger update
class Model {
var count = 0 // Not observable
}
struct ContentView: View {
let model = Model() // New instance each render, not observable
var body: some View {
Text("\(model.count)")
Button("Increment") {
model.count += 1 // View doesn't update
}
}
}
// ✅ RIGHT (iOS 17+): Use @Observable with @State
@Observable class Model {
var count = 0 // No @Published needed
}
struct ContentView: View {
@State private var model = Model() // @State, not @StateObject
var body: some View {
Text("\(model.count)")
Button("Increment") {
model.count += 1 // View updates
}
}
}
// ✅ RIGHT (iOS 17+): Injected @Observable objects
struct ContentView: View {
var model: Model // Just a plain property
var body: some View {
Text("\(model.count)") // View updates when count changes
}
}
// ✅ RIGHT (iOS 17+): @Observable with environment
@Observable class AppModel {
var count = 0
}
@main
struct MyApp: App {
@State private var model = AppModel()
var body: some Scene {
WindowGroup {
ContentView()
.environment(model) // Add to environment
}
}
}
struct ContentView: View {
@Environment(AppModel.self) private var model // Read from environment
var body: some View {
Text("\(model.count)")
}
}
// ✅ RIGHT (pre-iOS 17): Use @StateObject/ObservableObject
class Model: ObservableObject {
@Published var count = 0
}
struct ContentView: View {
@StateObject var model = Model() // For owned instances
var body: some View {
Text("\(model.count)")
Button("Increment") {
model.count += 1 // View updates
}
}
}
// ✅ RIGHT (pre-iOS 17): Use @ObservedObject for injected instances
struct ContentView: View {
@ObservedObject var model: Model // Passed in from parent
var body: some View {
Text("\(model.count)")
}
}
Fix it (iOS 17+) : Use @Observable macro on your class, then @State to store it. Views automatically track dependencies on properties they read.
Fix it (pre-iOS 17) : Use @StateObject if you own the object, @ObservedObject if it's injected, or @EnvironmentObject if it's shared across the tree.
Why @Observable is better (iOS 17+):
@Published wrapper needed@State instead of @StateObject@ObservedObjectSee also : Managing model data in your app
digraph view_not_updating {
start [label="View not updating?" shape=diamond];
reproduce [label="Can reproduce in preview?" shape=diamond];
cause [label="What changed?" shape=diamond];
start -> reproduce;
reproduce -> cause [label="yes: bug in code"];
reproduce -> "Cache/Xcode state → Preview Crashes" [label="no"];
cause -> "Struct Mutation" [label="modified struct directly"];
cause -> "Lost Binding Identity" [label="passed binding to child"];
cause -> "Accidental Recreation" [label="view inside conditional"];
cause -> "Missing Observer" [label="object changed, view didn't"];
}
When your preview won't load or crashes immediately, the three root causes are distinct.
Root cause : Preview missing a required dependency (@EnvironmentObject, @Environment, imported module).
// ❌ WRONG: ContentView needs a model, preview doesn't provide it
struct ContentView: View {
@EnvironmentObject var model: AppModel
var body: some View {
Text(model.title)
}
}
#Preview {
ContentView() // Crashes: model not found
}
// ✅ RIGHT: Provide the dependency
#Preview {
ContentView()
.environmentObject(AppModel())
}
// ✅ ALSO RIGHT: Check for missing imports
// If using custom types, make sure they're imported in preview file
#Preview {
MyCustomView() // Make sure MyCustomView is defined or imported
}
Fix it : Trace the error, find what's missing, provide it to the preview.
Root cause : State initialization failed at runtime. The view tried to access data that doesn't exist.
// ❌ WRONG: Index out of bounds at runtime
struct ListView: View {
@State var selectedIndex = 10
let items = ["a", "b", "c"]
var body: some View {
Text(items[selectedIndex]) // Crashes: index 10 doesn't exist
}
}
// ❌ WRONG: Optional forced unwrap fails
struct DetailView: View {
@State var data: Data?
var body: some View {
Text(data!.title) // Crashes if data is nil
}
}
// ✅ RIGHT: Safe defaults
struct ListView: View {
@State var selectedIndex = 0 // Valid index
let items = ["a", "b", "c"]
var body: some View {
if selectedIndex < items.count {
Text(items[selectedIndex])
}
}
}
// ✅ RIGHT: Handle optionals
struct DetailView: View {
@State var data: Data?
var body: some View {
if let data = data {
Text(data.title)
} else {
Text("No data")
}
}
}
Fix it : Review your @State initializers. Check array bounds, optional unwraps, and default values.
Root cause : Xcode cache corruption. The preview process has stale information about your code.
Diagnostic checklist :
Fix it (in order):
Cmd+Option+Prm -rf ~/Library/Developer/Xcode/DerivedDataCmd+BIf still broken after all four steps: It's not cache, see Error Types 1 or 2.
digraph preview_crashes {
start [label="Preview crashes?" shape=diamond];
error [label="Error message visible?" shape=diamond];
start -> error;
error -> "Missing Dependency" [label="'Cannot find in scope'"];
error -> "State Init Failure" [label="'Fatal error' or silent crash"];
error -> "Cache Corruption" [label="no error"];
"Cache Corruption" -> "Restart Preview → Restart Xcode → Nuke DerivedData";
}
Layout problems are usually visually obvious. Match your symptom to the pattern.
Symptom : Views stacked on top of each other, some invisible.
Root cause : Z-order is wrong or you're not controlling visibility.
// ❌ WRONG: Can't see the blue view
ZStack {
Rectangle().fill(.blue)
Rectangle().fill(.red)
}
// ✅ RIGHT: Use zIndex to control layer order
ZStack {
Rectangle().fill(.blue).zIndex(0)
Rectangle().fill(.red).zIndex(1)
}
// ✅ ALSO RIGHT: Hide instead of removing from hierarchy
ZStack {
Rectangle().fill(.blue)
Rectangle().fill(.red).opacity(0.5)
}
Symptom : View is tiny or taking up the entire screen unexpectedly.
Root cause : GeometryReader sizes itself to available space; parent doesn't constrain it.
// ❌ WRONG: GeometryReader expands to fill all available space
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
Button("Next") { }
}
// Text takes entire remaining space
// ✅ RIGHT: Constrain the geometry reader
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
.frame(height: 100)
Button("Next") { }
}
Symptom : Content hidden behind notch, or not using full screen space.
Root cause : .ignoresSafeArea() applied to wrong view.
// ❌ WRONG: Only the background ignores safe area
ZStack {
Color.blue.ignoresSafeArea()
VStack {
Text("Still respects safe area")
}
}
// ✅ RIGHT: Container ignores, children position themselves
ZStack {
Color.blue
VStack {
Text("Can now use full space")
}
}
.ignoresSafeArea()
// ✅ ALSO RIGHT: Be selective about which edges
ZStack {
Color.blue
VStack { ... }
}
.ignoresSafeArea(edges: .horizontal) // Only horizontal
Symptom : Text truncated, buttons larger than text, sizing behavior unpredictable.
Root cause : Mixing frame() (constrains) with fixedSize() (expands to content).
// ❌ WRONG: fixedSize() overrides frame()
Text("Long text here")
.frame(width: 100)
.fixedSize() // Overrides the frame constraint
// ✅ RIGHT: Use frame() to constrain
Text("Long text here")
.frame(width: 100, alignment: .leading)
.lineLimit(1)
// ✅ RIGHT: Use fixedSize() only for natural sizing
VStack(spacing: 0) {
Text("Small")
.fixedSize() // Sizes to text
Text("Large")
.fixedSize()
}
Symptom : Padding, corners, or shadows appearing in wrong place.
Root cause : Applying modifiers in wrong order. SwiftUI applies bottom-to-top.
// ❌ WRONG: Corners applied after padding
Text("Hello")
.padding()
.cornerRadius(8) // Corners are too large
// ✅ RIGHT: Corners first, then padding
Text("Hello")
.cornerRadius(8)
.padding()
// ❌ WRONG: Shadow after frame
Text("Hello")
.frame(width: 100)
.shadow(radius: 4) // Shadow only on frame bounds
// ✅ RIGHT: Shadow includes all content
Text("Hello")
.shadow(radius: 4)
.frame(width: 100)
SwiftUI uses view identity to track views over time, preserve state, and animate transitions. Understanding identity is critical for debugging state preservation and animation issues.
Position in view hierarchy determines identity:
VStack {
Text("First") // Identity: VStack.child[0]
Text("Second") // Identity: VStack.child[1]
}
When structural identity changes :
if showDetails {
DetailView() // Identity changes when condition changes
SummaryView()
} else {
SummaryView() // Same type, different position = different identity
}
Problem : SummaryView gets recreated each time, losing @State values.
You control identity with .id() modifier:
DetailView()
.id(item.id) // Explicit identity tied to item
// When item.id changes → SwiftUI treats as different view
// → @State resets
// → Animates transition
Symptom : @State values reset to initial values when you don't expect.
Cause : View identity changed (position in hierarchy or .id() value changed).
// ❌ PROBLEM: Identity changes when showDetails toggles
@State private var count = 0
var body: some View {
VStack {
if showDetails {
CounterView(count: $count) // Position changes
}
Button("Toggle") {
showDetails.toggle()
}
}
}
// ✅ FIX: Stable identity with .opacity()
var body: some View {
VStack {
CounterView(count: $count)
.opacity(showDetails ? 1 : 0) // Same identity always
Button("Toggle") {
showDetails.toggle()
}
}
}
// ✅ ALSO FIX: Explicit stable ID
var body: some View {
VStack {
if showDetails {
CounterView(count: $count)
.id("counter") // Stable ID
}
Button("Toggle") {
showDetails.toggle()
}
}
}
Symptom : View changes but doesn't animate.
Cause : Identity changed, SwiftUI treats as remove + add instead of update.
// ❌ PROBLEM: Identity changes with selection
ForEach(items) { item in
ItemView(item: item)
.id(item.id + "-\(selectedID)") // ID changes when selection changes
}
// ✅ FIX: Stable identity
ForEach(items) { item in
ItemView(item: item, isSelected: item.id == selectedID)
.id(item.id) // Stable ID
}
Symptom : List items jump around or animate incorrectly.
Cause : Non-unique or changing identifiers.
// ❌ WRONG: Index-based ID changes when array changes
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
Text(item.name)
}
// ❌ WRONG: Non-unique IDs
ForEach(items, id: \.category) { item in // Multiple items per category
Text(item.name)
}
// ✅ RIGHT: Stable, unique IDs
ForEach(items, id: \.id) { item in
Text(item.name)
}
// ✅ RIGHT: Make type Identifiable
struct Item: Identifiable {
let id = UUID()
var name: String
}
ForEach(items) { item in // id: \.id implicit
Text(item.name)
}
Use .id() to :
Example: Force recreation on data change :
DetailView(item: item)
.id(item.id) // New item → new view → @State resets
Don't use .id() when :
var body: some View {
let _ = Self._printChanges()
// Check if "@self changed" appears when you don't expect
}
Search codebase for .id() - are IDs changing unexpectedly?
Views in if/else change position → different identity.
Fix : Use .opacity() or stable .id() instead.
| Symptom | Likely Cause | Fix |
|---|---|---|
| State resets | Identity change | Use .opacity() instead of if |
| No animation | Identity change | Remove .id() or use stable ID |
| ForEach jumps | Non-unique ID | Use unique, stable IDs |
| Unexpected recreation | Conditional position | Add explicit .id() |
See also : WWDC21: Demystify SwiftUI
When you're under deadline pressure, you'll be tempted to shortcuts that hide problems instead of fixing them.
The danger : You skip diagnosis, cache issue recurs after 2 weeks in production, you're debugging while users hit crashes.
What to do instead (5-minute protocol, total):
Cmd+Option+P (30 seconds)rm -rf ~/Library/Developer/Xcode/DerivedData (30 seconds)Cmd+B (2 minutes)Time cost : 5 minutes diagnosis + 2 minutes fix = 7 minutes total
Cost of skipping : 30 min shipping + 24 hours debug cycle = 24+ hours total
The danger : You're treating symptoms, not diagnosing. Same view won't update in other contexts. You've just hidden the bug.
What to do instead (2-minute diagnosis):
Decision principle : If you can't name the specific root cause, you haven't diagnosed yet. Don't code until you can answer "the problem is struct mutation because...".
The danger : You're exhausted after 2 hours of guessing. You're 17 hours from App Store submission. You're panicking. Every minute feels urgent, so you stop diagnosing and start flailing.
Intermittent bugs are the MOST important to diagnose correctly. One wrong guess now creates a new bug. You ship with a broken view AND a new bug. App Store rejects you. You miss launch.
What to do instead (60-minute systematic diagnosis):
Step 1: Reproduce in preview (15 min)
Step 2: Isolate the variable (15 min)
if logic that might recreate the parent? Remove it and testStep 3: Apply the specific fix (30 min)
.opacity() instead of conditionalsStep 4: Verify 100% reliability (until submission)
Time cost : 60 minutes diagnosis + 30 minutes fix + confidence = submit at 9am
Cost of guessing : 2 hours already + 3 more hours guessing + new bug introduced + crash reports post-launch + emergency patch + reputation damage = miss launch + post-launch chaos
The decision principle : Intermittent bugs require SYSTEMATIC diagnosis. The slower you go in diagnosis, the faster you get to the fix. Guessing is the fastest way to disaster.
"I appreciate the suggestion. Adding @ObservedObject everywhere is treating the symptom, not the root cause. The skill says intermittent bugs create NEW bugs when we guess. I need 60 minutes for systematic diagnosis. If I can't find the root cause by then, we'll disable the feature and ship a clean v1.1. The math shows we have time—I can complete diagnosis, fix, AND verification before the deadline."
The danger : Magic numbers break on other sizes. SafeArea ignoring is often wrong. Locking to iPhone means you ship a broken iPad experience.
What to do instead (3-minute diagnosis):
Time cost : 3 minutes diagnosis + 5 minutes fix = 8 minutes total
Cost of magic numbers : Ship wrong, report 2 weeks later, debug 4 hours, patch in update = 2+ weeks delay
// Fix 1: Reassign the full struct
@State var items: [String] = []
var newItems = items
newItems.append("new")
self.items = newItems
// Fix 2: Pass binding correctly
@State var value = ""
ChildView(text: $value) // Pass binding, not value
// Fix 3: Preserve view identity
View().opacity(isVisible ? 1 : 0) // Not: if isVisible { View() }
// Fix 4: Observe the object
@StateObject var model = MyModel()
@ObservedObject var model: MyModel
// Fix 1: Provide dependencies
#Preview {
ContentView()
.environmentObject(AppModel())
}
// Fix 2: Safe defaults
@State var index = 0 // Not 10, if array has 3 items
// Fix 3: Nuke cache
// Terminal: rm -rf ~/Library/Developer/Xcode/DerivedData
// Fix 1: Z-order
Rectangle().zIndex(1)
// Fix 2: Constrain GeometryReader
GeometryReader { geo in ... }.frame(height: 100)
// Fix 3: SafeArea
ZStack { ... }.ignoresSafeArea()
// Fix 4: Modifier order
Text().cornerRadius(8).padding() // Corners first
Scenario : You have a list of tasks. When you tap a task to mark it complete, the checkmark should appear, but it doesn't.
Code :
struct TaskListView: View {
@State var tasks: [Task] = [...]
var body: some View {
List {
ForEach(tasks, id: \.id) { task in
HStack {
Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle")
Text(task.title)
Spacer()
Button("Done") {
// ❌ WRONG: Direct mutation
task.isComplete.toggle()
}
}
}
}
}
}
Diagnosis using the skill :
Fix :
Button("Done") {
// ✅ RIGHT: Full reassignment
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index].isComplete.toggle()
}
}
Why this works : SwiftUI detects the array reassignment, triggering a redraw. The task in the List updates.
Scenario : You created a custom data model. It works fine in the app, but the preview crashes with "Cannot find 'CustomModel' in scope".
Code :
import SwiftUI
// ❌ WRONG: Preview missing the dependency
#Preview {
TaskDetailView(task: Task(...))
}
struct TaskDetailView: View {
@Environment(\.modelContext) var modelContext
let task: Task // Custom model
var body: some View {
Text(task.title)
}
}
Diagnosis using the skill :
Fix :
#Preview {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Task.self, configurations: config)
return TaskDetailView(task: Task(title: "Sample"))
.modelContainer(container)
}
Why this works : Providing the environment object and model container satisfies the view's dependencies. Preview loads successfully.
Scenario : You have a search field. You type characters, but the text doesn't appear in the UI. However, the search results DO update.
Code :
struct SearchView: View {
@State var searchText = ""
var body: some View {
VStack {
// ❌ WRONG: Passing constant binding
TextField("Search", text: .constant(searchText))
Text("Results for: \(searchText)") // This updates
List {
ForEach(results(for: searchText), id: \.self) { result in
Text(result)
}
}
}
}
func results(for text: String) -> [String] {
// Returns filtered results
}
}
Diagnosis using the skill :
Fix :
// ✅ RIGHT: Pass the actual binding
TextField("Search", text: $searchText)
Why this works : $searchText passes a two-way binding. TextField writes changes back to @State, triggering a redraw. Text field now shows typed characters.
After fixing SwiftUI issues, verify with visual confirmation in the simulator.
SwiftUI previews don't always match simulator behavior:
Use simulator verification for :
# 1. Take "before" screenshot
/axiom:screenshot
# 2. Apply your fix
# 3. Rebuild and relaunch
xcodebuild build -scheme YourScheme
# 4. Take "after" screenshot
/axiom:screenshot
# 5. Compare screenshots to verify fix
If the bug is deep in your app, use debug deep links to navigate directly:
# 1. Add debug deep links (see deep-link-debugging skill)
# Example: debug://settings, debug://recipe-detail?id=123
# 2. Navigate and capture
xcrun simctl openurl booted "debug://problem-screen"
sleep 1
/axiom:screenshot
For complex scenarios (state setup, multiple steps, log analysis):
/axiom:test-simulator
Then describe what you want to test:
Before fix (view not updating):
# 1. Reproduce bug
xcrun simctl openurl booted "debug://recipe-list"
sleep 1
xcrun simctl io booted screenshot /tmp/before-fix.png
# Screenshot shows: Tapping star doesn't update UI
After fix (added @State binding):
# 2. Test fix
xcrun simctl openurl booted "debug://recipe-list"
sleep 1
xcrun simctl io booted screenshot /tmp/after-fix.png
# Screenshot shows: Star updates immediately when tapped
Time saved : 60%+ faster iteration with visual verification vs manual navigation
WWDC : 2025-256, 2025-306, 2023-10160, 2023-10149, 2021-10022
Docs : /swiftui/managing-model-data-in-your-app, /swiftui, /swiftui/state-and-data-flow, /xcode/previews, /observation
Skills : axiom-swiftui-performance, axiom-swiftui-debugging-diag, axiom-xcode-debugging, axiom-swift-concurrency, axiom-lldb (LLDB debugging workflows beyond Self._printChanges)
Weekly Installs
118
Repository
GitHub Stars
601
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode101
codex96
gemini-cli92
claude-code92
cursor89
github-copilot87
营销线索生成指南:B2B销售漏斗设计、线索定义与内容门控策略
158 周安装
TUI 设计系统:构建卓越终端用户界面的通用设计模式与最佳实践
158 周安装
飞书云文档权限管理工具 - 命令行批量管理协作者、公开权限、分享密码、所有权转移
158 周安装
LLM与智能体可观测性技能:基于Elastic数据监控AI性能、成本与调用链
158 周安装
Postman 集合生成器 - 自动从代码库生成 API 测试集合,支持 Express/Next.js/Fastify 等框架
158 周安装
ATS优化简历生成器 - 专业简历构建工具,支持PDF/DOCX/JSON等多格式导出,提升求职成功率
158 周安装