SwiftUI 界面动画调试一例:做码农最重要的是什么?相信自己!

62 阅读5分钟

在这里插入图片描述

概述

这是一篇关于 SwiftUI 界面调试的“小栗子”,确切地说是关于 SwiftUI 动画在 Xcode 预览中运行“恢诡谲怪”的一桩“咄咄怪事”。

在这里插入图片描述

我们自认为绝对没错的解决方法竟然“失灵了”?真相到底是什么?看我们如何让谜底水落石出!

在本篇博文中,您将学到如下内容:

  1. 不该发生的动画!
  2. 跟着感觉走
  3. 莫须有的罪名?让我们凭空捏造!
  4. 拔本塞源:元凶竟然还是它!?

本篇代码测试环境为 macOS 15.1.1 + Xcode 16.1

信自己,得永生!码农自然也不例外!不信?空口无凭,让我们撸吗为证!

Let's go!!!;)


1. 不该发生的动画!

下面这段代码要做的事很简单:我们希望在指尖长按圆形时做一些事,同时显示一段颇为 nice 的动画。

@available(iOS 17, *)
struct FingersJail: View {
    @State var isLockdown = false
    @State var lockdownDuration = 0.0
    @State var success = false
    @State var animBG = false
    @State var pressing = false
    @State var cancel: AnyCancellable?

    private let TargetDuration = 5.0
        
    var body: some View {
        ZStack {
            HStack {                
                Circle()
                    .frame(width: 150)
                    .foregroundStyle(.clear)
                    .overlay {
                        Circle()
                            .foregroundStyle(animBG ? Color.red.opacity(0.66) : .red)
                            .animation(.easeInOut(duration: 1.0).repeatForever(), value: animBG)
                            .transaction { trans in
                                trans.disablesAnimations = !animBG
                            }
                            .frame(width: pressing ? 120 : 150)
                            .animation(.bouncy, value: pressing)
                            .overlay {
                                Circle()
                                    .strokeBorder(pressing ? Color.red.gradient : AnyGradient(.init(colors: [.clear])), lineWidth: 20.0)
                            }
                            .overlay {
                                if pressing {
                                    Image(systemName: "hand.point.up.left.fill")
                                        .foregroundStyle(.white.gradient)
                                        .font(.system(size: 150 / 3, weight: .heavy, design: .rounded))
                                        .shadow(radius: 3.0)
                                }
                            }
                    }
                    .onLongPressGesture {
                        
                    } onPressingChanged: { pressing in
                        
                        self.pressing = pressing
                        
                    }
                    .onChange(of: pressing) {_,new in
                        animBG = new
                    }
            }
        }
        
        .onChange(of: pressing) {_, _ in
            isLockdown = pressing
            
            if isLockdown {
                guard cancel == nil else { return }
                cancel = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect().sink { _ in
                    lockdownDuration += 1.0
                    if lockdownDuration >= TargetDuration {
                        cancel?.cancel()
                        cancel = nil
                        success = true
                    }
                }
            } else {
                cancel?.cancel()
                cancel = nil
                lockdownDuration = 0.0
            }
        }
        .navigationTitle("手指监牢")
        .safeAreaInset(edge: .bottom) {
            if success {
                Text("刑满释放!请保持慎独!")
                    .font(.title2.bold())
                    .foregroundStyle(.green)
            } else {
                if isLockdown {
                    VStack {
                        Text("所有手指都已锁住!!![\(lockdownDuration, specifier: "%0.1f")]")
                        Text("请保持 \(TargetDuration, specifier: "%.0f") 秒")
                            .foregroundStyle(.gray)
                    }
                }
            }
        }
    }
}

不过如果我们在 Xcode 预览中运行它,会发现运行效果有点小瑕疵:就是动画会导致圆圈背景产生上下位移。

在这里插入图片描述

这可不是我们想要的结果!所以,让我们开动脑筋探根究底一番!

2. 跟着感觉走

观察源代码可以看到,为了避免多个状态造成所谓的“动画污染”,我们特地用 animation(:value:) 构造器试图将导致动画的状态“圈禁”了起来,下面省略了无关的代码:

Circle()
    .animation(.easeInOut(duration: 1.0).repeatForever(), value: animBG)
    .animation(.bouncy, value: pressing)

如上代码所示,我们使用隐式动画将动画的显示限制在了指定的状态(animBG 和 pressing)上。

观察代码容易发现:我们红圆在动画时产生的位移实际上是由于 isLockdown 状态的改变导致的。 按照传统的经验,只需要明确让 SwiftUI 懂得我们不希望在 isLockdown 发生改变时产生任何动画即可,这可以通过显式动画来搞定:

withAnimation(.none) {
    isLockdown = pressing
}

但是,在 Xcode 预览重新运行修改后的代码依然会发现:一切都没有任何改变!这是怎么回事呢?

3. 莫须有的罪名?让我们凭空捏造!

上面试图用显式动画消除动画副作用的操作竟然是“不舞之鹤”!?这不禁让我们对秃头自己“高超"的撸码技术产生了一丢丢的怀疑。

实际情况难道是:显式限制 isLockdown 状态去禁用动画不起作用?

聪明的我们马上使用迂回测试套路,将 isLockdown 从 @State 改为 @Observable 类型状态:

@available(iOS 17, *)
@Observable
class Model {
    var isLockdown = false
}

@available(iOS 17, *)
struct FingersJail: View {
    let model = Model()
    //...
    
    var body: some View {
        ZStack {
            HStack {
                Circle()
                    // 省略重复代码...
            }
        }
        .onChange(of: pressing) {_, _ in
            withAnimation(.none) {
                model.isLockdown = pressing }
            }
                        
            if model.isLockdown {
                //...
            } else {
                //...
            }
        }
        .safeAreaInset(edge: .bottom) {
            if success {
                Text("刑满释放!请保持慎独!")
                    .font(.title2.bold())
                    .foregroundStyle(.green)
            } else {
                if model.isLockdown {
                    //...
                }
            }
        }
    }
}

重新在 Xcode 预览中验证结果,竟然一切正常:

在这里插入图片描述

如果将上面的 @Observable 状态换为 ObservableObject 也是可以的:

class Model: ObservableObject {
    @Published var isLockdown = false
}

@available(iOS 17, *)
struct FingersJail: View {
    @StateObject var model = Model()
}

如果按此逻辑,原来视图中 @State 状态和 @Observable 与 ObservableObject 状态在使用上是有细微差别的,至少在动画的参与上它们是不同的:“内部的”@State 不管用,“外部的” @Observable 或 ObservableObject 才是“拯救者”?

从实验结果来看,我们上面的推理貌似是合理的,但结论看似不大可能!

所以,真实的故事果真是如此吗?

4. 拔本塞源:元凶竟然还是它!?

我在很早就开始通过 Xcode 预览(Preview)来调试 App 界面了,过来的小伙伴们都知道 Xcode 预览是一个“不太靠谱”的界面调试器。简单的布局它当然可以从容面对,但是对于比较复杂或者某些可能触痛它“痛点”的界面预览可能产生“光怪陆离”的结果。

那么上面的例子也是这样吗?

验证的方法很简单:只需在模拟器或真机中运行一番就可以了!

将 isLockdown 仍然改成 @State 状态,并恢复原先与之相关的代码,接着在模拟器中重复运行一见分晓:

在这里插入图片描述

看到了吗?其实我们一开始就已经用显式动画解决了这个问题,无奈 Xcode 预览不给力让我们误以为没有效果,甚至对自己产生了深深的怀疑和自责,真是令人火冒三丈!

这种特别 low 的 bug 在早期版本的 Xcode 中出现还情有可原,现在都 Xcode 16.1 了还没解决,这就有点儿说不过去了。

所以小伙伴们了然了吗?任何时候都要相信自己!相信自己的直觉、相信自己的经验!

人生看淡,不服就干!棒棒哒!💯

总结

在本篇博文中,我们通过一个小小“栗子”导致的“焦头烂额”让小伙伴们懂得了相信自己的重要性,很赞哦!

感谢观赏,再会了 8-)