概述
这是一篇关于 SwiftUI 界面调试的“小栗子”,确切地说是关于 SwiftUI 动画在 Xcode 预览中运行“恢诡谲怪”的一桩“咄咄怪事”。
我们自认为绝对没错的解决方法竟然“失灵了”?真相到底是什么?看我们如何让谜底水落石出!
在本篇博文中,您将学到如下内容:
- 不该发生的动画!
- 跟着感觉走
- 莫须有的罪名?让我们凭空捏造!
- 拔本塞源:元凶竟然还是它!?
本篇代码测试环境为 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-)