[译]高级 SwiftUI 动画 — Part 3:AnimatableModifier

我们已经看到 Animatable 协议是如何帮助我们实现路径和变换矩阵的动画化的。在本系列的最后一部分,我们将更进一步。AnimatableModifier 是三者中最强大的。有了它,我们可以不受限制地完成任务。

这个名字说明了一切: AnimatableModifier。它是一个 ViewModifier,符合 Animatable。如果你不知道 AnimatableanimatableData 是如何工作的,请先到本系列的第一部分去看看。

iOS 15补充:
现在,视图协议可以准守 Animatable 协议,而且 AnimatableModifier 已被弃用。不过,本文的大部分内容也适用于 Animatable 视图。在文章的最后,我将向你展示如何编写一个 Animatable 视图。它与 AnimatableModifier 基本相同,但更为简单!

好吧,让我们暂停一下,想想拥有一个可动画的修饰符意味着什么......你可能认为这好得不像真的。我真的可以通过一个动画多次修改我的视图吗?答案很简单:是的,你可以。

本文的完整示例代码可在以下位置找到:

https://gist.github.com/swiftui-lab/e5901123101ffad6d39020cc7a810798

示例8 需要的图片资源。从这里下载:

https://swiftui-lab.com/?smd_process_download=1&download_id=916

AnimatableModifier不能实现动画! 为什么?

如果你打算在生产代码中使用 AnimatableModifier,请确保阅读本文最后一节: 与版本共舞。

如果你自己尝试使用了这个协议,有可能你一开始就会碰壁。我当然也是一样。在我的第一次尝试中,我写了一个非常简单的可动画的修饰符,但是,视图并没有动画化。我又试了几次,都没有效果。由于我们处于早期的测试阶段,我认为这个功能就是不存在的,于是就完全放弃了它。幸运的是,我后来坚持了下来。让我强调这个词:"幸运地"。事实证明,我的第一个修饰符是完美的,但可动画的修饰符在容器内是不工作的。恰好我第二次尝试时,我的视图不在容器内。如果我不是那么幸运,你就不会看到这第三篇文章了。

例如,以下 modifier 可以成功实现动画:

MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))

但是相同的代码,在 VStack 中就没有动画了:

VStack {
    MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))
}

这个问题在官方解决之前,经过尝试,可以在 VStack 中改成下面的代码,就可以实现动画:

VStack {
    Color.clear.overlay(MyView().modifier(MyAnimatableModifier(value: flag ? 1 : 0))).frame(width: 100, height: 100)
}

我们基本上是用一个透明的视图来占据我们的实际视图的空间,这个视图将被放置在它上面,使用.overlay()。唯一不方便的是,我们需要知道实际视图有多大,这样我们就可以在它后面设置透明视图的大小。这有时会很棘手,但我们必须要有一些技巧。我们将在下面的例子中看到。

## 动画文本

首先需要制作一些文字动画。对于这个例子,我们将创建一个进度加载指示器,它将是一个带有标签的 Label:

可能很多人都认为应该使用动画路径实现。但是那样的话,内部标签就无法设置动画,然而使用 AnimatableModifier 可以实现。

完整的代码作为 示例10 在文章开始的链接中。关键代码如下:

struct PercentageIndicator: AnimatableModifier {
    var pct: CGFloat = 0
    
    var animatableData: CGFloat {
        get { pct }
        set { pct = newValue }
    }
    
    func body(content: Content) -> some View {
        content
            .overlay(ArcShape(pct: pct).foregroundColor(.red))
            .overlay(LabelView(pct: pct))
    }
    
    struct ArcShape: Shape {
        let pct: CGFloat
        
        func path(in rect: CGRect) -> Path {

            var p = Path()

            p.addArc(center: CGPoint(x: rect.width / 2.0, y:rect.height / 2.0),
                     radius: rect.height / 2.0 + 5.0,
                     startAngle: .degrees(0),
                     endAngle: .degrees(360.0 * Double(pct)), clockwise: false)

            return p.strokedPath(.init(lineWidth: 10, dash: [6, 3], dashPhase: 10))
        }
    }
    
    struct LabelView: View {
        let pct: CGFloat
        
        var body: some View {
            Text("\(Int(pct * 100)) %")
                .font(.largeTitle)
                .fontWeight(.bold)
                .foregroundColor(.white)
        }
    }
}

正如你在例子中所看到的,我们没有让ArcShape成为可动画的。这是没有必要的,因为修饰符已经在用不同的 pct 值多次创建形状。

## 渐变动画

如果你曾经尝试对渐变进行动画处理,你可能发现有一些限制。例如,你可以对起点和终点制作动画,但你不能对渐变的颜色制作动画。在这里,我们也可以从AnimatableModifier中获益。

很容易就可以实现这个功能,在这个基础上可以实现更多复杂的动画。如果需要插入中间颜色,我们只需要计算 RGB 值的平均值。另外需要注意,modifier 假设输入颜色数组都包含相同数量的颜色。

完整的代码作为 示例11 在文章开始的链接中。关键代码如下:

struct AnimatableGradient: AnimatableModifier {
    let from: [UIColor]
    let to: [UIColor]
    var pct: CGFloat = 0
    
    var animatableData: CGFloat {
        get { pct }
        set { pct = newValue }
    }
    
    func body(content: Content) -> some View {
        var gColors = [Color]()
        
        for i in 0.. Color {
        guard let cc1 = c1.cgColor.components else { return Color(c1) }
        guard let cc2 = c2.cgColor.components else { return Color(c1) }
        
        let r = (cc1[0] + (cc2[0] - cc1[0]) * pct)
        let g = (cc1[1] + (cc2[1] - cc1[1]) * pct)
        let b = (cc1[2] + (cc2[2] - cc1[2]) * pct)

        return Color(red: Double(r), green: Double(g), blue: Double(b))
    }
}

更多文本动画

在我们的下一个例子中,我们将再次为文本制作动画。不过,在这种情况下,我们将逐步地做:一次一个字符。

平滑的渐进式缩放需要一些数学运算,但结果是值得的。完整的代码可在本页顶部链接的gist文件中的Example12中找到。

struct WaveTextModifier: AnimatableModifier {
    let text: String
    let waveWidth: Int
    var pct: Double
    var size: CGFloat
    
    var animatableData: Double {
        get { pct }
        set { pct = newValue }
    }
    
    func body(content: Content) -> some View {
        
        HStack(spacing: 0) {
            ForEach(Array(text.enumerated()), id: \.0) { (n, ch) in
                Text(String(ch))
                    .font(Font.custom("Menlo", size: self.size).bold())
                    .scaleEffect(self.effect(self.pct, n, self.text.count, Double(self.waveWidth)))
            }
        }
    }
    
    func effect(_ pct: Double, _ n: Int, _ total: Int, _ waveWidth: Double) -> CGFloat {
        let n = Double(n)
        let total = Double(total)
        
        return CGFloat(1 + valueInCurve(pct: pct, total: total, x: n/total, waveWidth: waveWidth))
    }
    
    func valueInCurve(pct: Double, total: Double, x: Double, waveWidth: Double) -> Double {
        let chunk = waveWidth / total
        let m = 1 / chunk
        let offset = (chunk - (1 / total)) * pct
        let lowerLimit = (pct - chunk) + offset
        let upperLimit = (pct) + offset
        guard x >= lowerLimit && x < upperLimit else { return 0 }
        
        let angle = ((x - pct - offset) * m)*360-90
        
        return (sin(angle.rad) + 1) / 2
    }
}

extension Double {
    var rad: Double { return self * .pi / 180 }
    var deg: Double { return self * 180 / .pi }
}

## 发挥你的创意

在我们对AnimatableModifier有所了解之前,下面这个例子可能看起来不可能实现。我们的下一个挑战是创建一个计数器。

这个练习的诀窍是为每个数字使用5个文本视图,用.spring()动画上下移动它们。我们还需要使用一个.clipShape()修饰符,来隐藏画在边界外的部分。为了更好地理解它的工作原理,你可以对.clipShape()进行注释,并大大降低动画的速度。

完整的代码可以在本页顶部链接的gist文件中以Example13的形式获得。

struct MovingCounterModifier: AnimatableModifier {
        @State private var height: CGFloat = 0

        var number: Double
        
        var animatableData: Double {
            get { number }
            set { number = newValue }
        }
        
        func body(content: Content) -> some View {
            let n = self.number + 1
            
            let tOffset: CGFloat = getOffsetForTensDigit(n)
            let uOffset: CGFloat = getOffsetForUnitDigit(n)

            let u = [n - 2, n - 1, n + 0, n + 1, n + 2].map { getUnitDigit($0) }
            let x = getTensDigit(n)
            var t = [abs(x - 2), abs(x - 1), abs(x + 0), abs(x + 1), abs(x + 2)]
            t = t.map { getUnitDigit(Double($0)) }
            
            let font = Font.custom("Menlo", size: 34).bold()
            
            return HStack(alignment: .top, spacing: 0) {
                VStack {
                    Text("\(t[0])").font(font)
                    Text("\(t[1])").font(font)
                    Text("\(t[2])").font(font)
                    Text("\(t[3])").font(font)
                    Text("\(t[4])").font(font)
                }.foregroundColor(.green).modifier(ShiftEffect(pct: tOffset))
                
                VStack {
                    Text("\(u[0])").font(font)
                    Text("\(u[1])").font(font)
                    Text("\(u[2])").font(font)
                    Text("\(u[3])").font(font)
                    Text("\(u[4])").font(font)
                }.foregroundColor(.green).modifier(ShiftEffect(pct: uOffset))
            }
            .clipShape(ClipShape())
            .overlay(CounterBorder(height: $height))
            .background(CounterBackground(height: $height))
        }
        
        func getUnitDigit(_ number: Double) -> Int {
            return abs(Int(number) - ((Int(number) / 10) * 10))
        }
        
        func getTensDigit(_ number: Double) -> Int {
            return abs(Int(number) / 10)
        }
        
        func getOffsetForUnitDigit(_ number: Double) -> CGFloat {
            return 1 - CGFloat(number - Double(Int(number)))
        }
        
        func getOffsetForTensDigit(_ number: Double) -> CGFloat {
            if getUnitDigit(number) == 0 {
                return 1 - CGFloat(number - Double(Int(number)))
            } else {
                return 0
            }
        }
    }

## 文本颜色动画

如果你曾经尝试对.foregroundColor()进行动画处理,你可能已经注意到它工作得很好,除了当视图是文本类型时。我不知道这是个错误,还是功能缺失。尽管如此,如果你需要对文本的颜色进行动画处理,你可以用下面这样的AnimatableModifier来实现。完整的代码可以在本页顶部链接的gist文件中的Example14中找到。

struct AnimatableColorText: View {
    let from: UIColor
    let to: UIColor
    let pct: CGFloat
    let text: () -> Text
    
    var body: some View {
        let textView = text()
        
        return textView.foregroundColor(Color.clear)
            .overlay(Color.clear.modifier(AnimatableColorTextModifier(from: from, to: to, pct: pct, text: textView)))
    }
    
    struct AnimatableColorTextModifier: AnimatableModifier {
        let from: UIColor
        let to: UIColor
        var pct: CGFloat
        let text: Text
        
        var animatableData: CGFloat {
            get { pct }
            set { pct = newValue }
        }

        func body(content: Content) -> some View {
            return text.foregroundColor(colorMixer(c1: from, c2: to, pct: pct))
        }
        
        // This is a very basic implementation of a color interpolation
        // between two values.
        func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
            guard let cc1 = c1.cgColor.components else { return Color(c1) }
            guard let cc2 = c2.cgColor.components else { return Color(c1) }
            
            let r = (cc1[0] + (cc2[0] - cc1[0]) * pct)
            let g = (cc1[1] + (cc2[1] - cc1[1]) * pct)
            let b = (cc1[2] + (cc2[2] - cc1[2]) * pct)

            return Color(red: Double(r), green: Double(g), blue: Double(b))
        }
    }
}

## 与版本共舞

我们已经看到AnimatableModifier非常强大......但是,也有一点小毛病。最大的问题是,在Xcode和iOS/MacOS版本的某些组合下,应用程序会在启动时直接崩溃。更糟糕的是,这通常发生在部署应用程序时,但在正常开发期间用Xcode编译和运行时不会发生。你可能花了很多时间来开发和调试,认为一切都很好,但在部署时,你会得到这样的结果。

dyld: Symbol not found: _$s7SwiftUI18AnimatableModifierPAAE13_makeViewList8modifier6inputs4bodyAA01_fG7OutputsVAA11_GraphValueVyxG_AA01_fG6InputsVAiA01_L0V_ANtctFZ
  Referenced from: /Applications/MyApp.app/Contents/MacOS/MyApp
  Expected in: /System/Library/Frameworks/SwiftUI.framework/Versions/A/SwiftUI

例如,如果用Xcode 11.3部署应用程序,并在macOS 10.15.0上执行,它将无法启动,并出现 "没有找到符号 "的错误。然而,在10.15.1上运行相同的可执行文件却能正常工作。

相反,如果我们用Xcode 11.1进行部署,它在所有的macOS版本(至少我试过的版本)都能正常工作。

类似的情况也发生在iOS上。一个使用AnimatableModifier的应用程序,在Xcode 11.2中部署后,在iOS 13.2.2中无法启动,但在iOS 13.2.3中可以正常工作。

目前,我将继续使用Xcode 11.1用于我的需要AnimatableModifier的macOS项目。在未来,我可能会使用较新版本的Xcode,但将应用程序的要求提高到macOS 10.15.1(除非这个问题得到解决,我非常怀疑)。

Animatable View (iOS 15补充)

自 iOS15 和 macOS12 起,View 协议可以采用 Animatable 协议。这样就无需使用 AnimatableModifier 了。如果你知道如何使用 AnimatableModifier,通过这个快速示例,你就会发现更新代码是多么容易:

struct ExampleView: View {
    @State var animate = false
    
    var body: some View {
        CustomView(xoffset: animate ? 100 : -100)
            .task {
                withAnimation(.spring.repeatForever(autoreverses: true)) {
                    animate.toggle()
                }
            }
        
    }
}

struct CustomView: View {
    var xoffset: CGFloat
    
    var body: some View {
        Rectangle()
            .fill(.green.gradient)
            .frame(width: 30, height: 30)
            .offset(computedOffset())
    }
    
    func computedOffset() -> CGSize {
        return CGSize(width: xoffset, height: sin(xoffset/100 * .pi) * 100)
    }
}

该动画将使矩形从左到右直线移动。虽然垂直偏移量的计算公式为 sin(xoffset/100 * .pi) * 100,但在 x = -100.0 时,y 为 0.0,而在 x = 100.0 时,y 为 0.0。就 SwiftUI 而言,由于动画开始和结束时 y 均为 0.0,因此纵轴上没有任何动画。

如果我们希望在动画的每一帧中都重新计算视图主体,那么我们可以通过添加 AnimatableanimatableData 属性来采用 Animatable 协议。现在,在制作动画时,矩形将遵循正弦波路径:

struct CustomView: View, Animatable {
    var xoffset: CGFloat
    
    var animatableData: CGFloat {
        get { xoffset }
        set { xoffset = newValue }
    }
    
    var body: some View {
        Rectangle()
            .fill(.green.gradient)
            .frame(width: 30, height: 30)
            .offset(computedOffset())
    }
    
    func computedOffset() -> CGSize {
        return CGSize(width: xoffset, height: sin(xoffset/100 * .pi) * 100)
    }
}

小结

我们已经看到了Animatable协议有多简单,它有多大的作用。把你的创造力用于工作,结果将是惊人的。

译自 The SwiftUI Lab 的 Advanced SwiftUI Animations – Part 3: AnimatableModifier

你可能感兴趣的:([译]高级 SwiftUI 动画 — Part 3:AnimatableModifier)