iOS 17 测试版中 SwiftUI 视图首次显示时状态的改变导致动画“副作用”的解决方法

Source

在这里插入图片描述

问题现象

精彩的 SwiftUI 动画可以让我们的 App 活灵活现、精妙绝伦。不过原本正常的动画在测试版本的 iOS 里却有着让码农持续秃头的“副作用”:

在这里插入图片描述

我们希望在视图首次显示时驱动状态改变来产生橘色小球围绕红球旋转的动画,红球应该始终保持在屏幕中心。

可是从上图中可以看到,红色大球本身也发生了斜线移动。

该问题目前只存在于 iOS 17 (beta 4)测试版中,iOS 16.x 里一切正常。

这是什么原因?又该如何解决呢?

无需等待,Let‘s fix it!😉


1. 动画“副作用”的原因

首先,我们需要看一下源代码:

struct Planet: View {
    
      
    var angle: Angle
    let track_rect_side: CGFloat = 100.0
    
    var body: some View {
    
      
        ZStack {
    
      
            Circle()
                .fill(.red)
                .frame(width: 50, height: 50)
            
            Circle()
                .stroke(Color.gray, lineWidth: 1.0)
                .frame(width: track_rect_side, height: track_rect_side)
            
            Circle()
                .fill(.orange)
                .frame(width: 25, height: 25)
                .offset(x: track_rect_side/2.0 * cos(angle.radians), y: track_rect_side/2.0 * sin(angle.radians))
        }
        
    }
}

extension Planet: Animatable {
    
      
    var animatableData: Angle.AnimatableData {
    
      
        get {
    
       angle.animatableData }
        set {
    
       angle.animatableData = newValue }
    }
}

struct ContentView: View {
    
      
    @State var angle = Angle.zero
        
    var body: some View {
    
      
        NavigationStack {
    
      
            VStack {
    
                      
                Planet(angle: angle)
                    .animation(.linear(duration: 3.0).repeatForever(autoreverses: false), value: angle)
                    .onAppear {
    
      
                        angle = .degrees(360)
                    }
            }
            .frame(maxHeight: .infinity)
            .navigationTitle("带“副作用”的动画")
            .ignoresSafeArea()
        }
    }
}

上面的代码让 Planet 视图遵守 Animatable 动画协议来实现小球绕圈的自定义动画效果。


想进一步了解 SwiftUI 自定义动画的小伙伴们,请移步如下链接观赏更详细的内容:


从代码中可以看到,Planet 视图中 angle 状态的改变只会影响橘色小球的位置,为什么红色大球也会发生位移呢?

这疑似是 iOS 17 beta4(SwiftUI 5.0)测试版中的一个 Bug,因为从代码里看 angle 不会影响到 Planet 中的其它视图,更不会影响 Planet 自身。

而且在 iOS 16.4 (模拟器)和 iOS 16.6 (真机)中相同的代码都没有任何题!

那么,如何在 iOS 17 测试版中临时规避该问题呢?

2. 一种不妥当的解决方法

一种很简单但不怎么可靠的方法是:延时!

struct ContentView: View {
    
      
    @State var angle = Angle.zero
        
    var body: some View {
    
      
        NavigationStack {
    
      
            VStack {
    
                      
                Planet(angle: angle)
                    .animation(.linear(duration: 3.0).repeatForever(autoreverses: false), value: angle)
                    .onAppear {
    
      
                        // 略微延时以确保 Planet 处在屏幕中心!
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
    
      
                            angle = .degrees(360)
                        }
                    }
            }
            .frame(maxHeight: .infinity)
            .navigationTitle("带“副作用”的动画")
            .ignoresSafeArea()
        }
    }
}

如上代码所示,我们希望用延时来确保状态改变时 Planet 已位于屏幕中心。

不过,如果主视图有多重动画加持或主线程负载较大时,无法保证 Planet 视图会在特定延时后完成屏幕位置上的渲染(Render)。

一句话:延时可能时灵时不灵!

3. 绝薪止火:正确的解决之道

正确的解决方法是:在 Planet 的根视图中关闭“动画”:

struct Planet: View {
    
      
    var angle: Angle
    let track_rect_side: CGFloat = 100.0
    
    var body: some View {
    
      
        ZStack {
    
      
            Circle()
                .fill(.red)
                .frame(width: 50, height: 50)
            
            Circle()
                .stroke(Color.gray, lineWidth: 1.0)
                .frame(width: track_rect_side, height: track_rect_side)
            
            Circle()
                .fill(.orange)
                .frame(width: 25, height: 25)
                .offset(x: track_rect_side/2.0 * cos(angle.radians), y: track_rect_side/2.0 * sin(angle.radians))
        }
        // 在 Planet 根视图上“关闭动画”,这并不影响其内部的动画效果。
        .animation(.none)
    }
}

不过, animation(_😃 方法已废弃,不再推荐使用:

在这里插入图片描述

别急,我们还有另一种类似的方法:

struct Planet: View {
    
      
    var angle: Angle
    let track_rect_side: CGFloat = 100.0
    
    var body: some View {
    
      
        ZStack {
    
      
            Circle()
                .fill(.red)
                .frame(width: 50, height: 50)
            
            Circle()
                .stroke(Color.gray, lineWidth: 1.0)
                .frame(width: track_rect_side, height: track_rect_side)
            
            Circle()
                .fill(.orange)
                .frame(width: 25, height: 25)
                .offset(x: track_rect_side/2.0 * cos(angle.radians), y: track_rect_side/2.0 * sin(angle.radians))
        }
        .transaction {
    
       transaction in
            transaction.animation = .none
        }
    }
}

如上代码所示:我们利用 SwiftUI 动画实际都由 Transaction 承载这一原理,将 Planet 根视图上 Transaction 对应的动画设为“空”,同样解决了问题。

现在,在视图首显时一切都回归“初心”,棒棒哒:

在这里插入图片描述

最后要说明的是,如果该问题是 iOS 17(SwiftUI 5.0)测试版中的 Bug,那么它将很可能会在 iOS 17 的后续版本中被修复,让我们拭目以待!

总结

在本篇博文中,我们在 iOS 17 beta 4(SwiftUI 5.0)测试版中发现了 SwiftUI 视图首次显示时状态的改变会导致动画“副作用”的问题,并提出多种解决方案。

感谢观赏,再会!😎