SwiftUI

SwiftUI动画(3) AnimatableModifier

在前两篇文章中,我们已经讲解了如何使用AnimatableGeometryEffect来实现一些比较复杂的动画,其基本原理,是根据animatableData来自由控制形变。

这篇文章中,我们将带来更为强大的一个工具AnimatableModifier,它之所以强大,是因为它不仅实现了Animatable协议,还实现了ViewModifier协议,因此,我们能够利用这两个协议的优势。

基于ViewModifier,我们可以直接返回some View,这让我们能够不断的往原来的view上增加新的view。代码操作起来也更加灵活。


1. Animating Text

小试牛刀,我们看上边的gif图,这仍然是一个percent动画,根据percent(0~1),我们画一个路径,当然,该路径我们使用了path.strokedPath(.init(lineWidth: 10, dash: [6, 3], dashPhase: 20)).

其中dashPhase表示虚线开始的点,这里不多做解释,一个小技巧,利用这个值我们可以做一些有意思的动画:

struct ContentView: View {
    @State private var phase: CGFloat = 0

    var body: some View {
        Rectangle()
            .strokeBorder(style: StrokeStyle(lineWidth: 4, dash: [10], dashPhase: phase))
            .frame(width: 200, height: 200)
            .onAppear { self.phase -= 20 }
            .animation(Animation.linear.repeatForever(autoreverses: false))
    }
}

效果如下:

回到正文,大家仔细思考,我们如果用GeometryEffect也能实现最上边进度的问题,但是我们无法显示30%,40%这样的问题。看看实现代码:

struct Example10: View {
    @State private var pct: CGFloat = 0
    
    var body: some View {
        VStack {
            Spacer()
            
            Indicator(pct: pct)
            
            Spacer()
            
            HStack(spacing: 10) {
                MyButton(label: "20%", font: .subheadline) {
                    withAnimation(.easeInOut(duration: 1.0)) {
                        self.pct = 0.2
                    }
                }
                MyButton(label: "60%", font: .subheadline) {
                    withAnimation(.easeInOut(duration: 1.0)) {
                        self.pct = 0.6
                    }
                }
                MyButton(label: "100%", font: .subheadline) {
                    withAnimation(.easeInOut(duration: 1.0)) {
                        self.pct = 1.0
                    }
                }
            }
            
            Spacer()
        }
        
    }
}

struct Indicator: View {
    var pct: CGFloat
    
    var body: some View {
        Circle()
            .fill(LinearGradient(gradient: .init(colors: [.green, .orange]), startPoint: .topLeading, endPoint: .bottomTrailing))
            .frame(width: 150, height: 150)
        .modifier(IndicatorModirier(pct: pct))
    }
}

struct IndicatorModirier: 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(.orange))
            .overlay(LabelView(pct: pct))
    }
    
    struct ArcShape: Shape {
        var pct: CGFloat
        
        func path(in rect: CGRect) -> Path {
            var path = Path()
            
            path.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(Double(pct) * 360),
                        clockwise: false)
            return path.strokedPath(.init(lineWidth: 10, dash: [6, 3, 20, 3], dashPhase: 0))
        }
    }
    
    struct LabelView: View {
        var pct: CGFloat
        
        var body: some View {
            Text("\(pct * 100, specifier: "%.0f") %")
                .font(.largeTitle)
                .bold()
                .foregroundColor(.white)
        }
    }
}

其中,最核心的代码是下边这几行,有了content,你就可以做出你想要的任何动画样式。

    func body(content: Content) -> some View {
        content
            .overlay(ArcShape(pct: pct).foregroundColor(.orange))
            .overlay(LabelView(pct: pct))
    }

2. Animating Gradients

当我们平时需要对Gradient进行动画时会有一些限制,比如,我们可以对start point和end point进行动画,但是却无法对颜色进行动画。

但有了AnimatableModifier,就变得简单很多,我们先看把最终效果做进一步的拆分:

然后我们定义进度,我们仍然使用percent,它的值为0,0.1, 0.2 … 1,这个值是系统根据动画需要自动计算的。

大家想一想,这个矩形区域上有很多像素,我们不可能把每一个像素对应于percent的变化都计算出来,每一次percent变化,我们只需要应用一个LinearGradient就可以了:

    func body(content: Content) -> some View {
        var gColors = [Color]()
        
        for i in 0..<from.count {
            gColors.append(colorMixer(c1: from[i], c2: to[i], pct: pct))
        }
        
        return RoundedRectangle(cornerRadius: 15)
            .fill(LinearGradient(gradient: .init(colors: gColors), startPoint: .topLeading, endPoint: .bottomTrailing))
            .frame(width: 300, height: 300)
    }

从上边的代码可以看出,我们修改了gColors,而这个gColors是通过下边的代码计算出来的:

    func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
        let rgbSpace = CGColorSpaceCreateDeviceRGB()
        guard let cc1 = c1.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else {
            return Color(c1)
        }
        guard let cc2 = c2.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else {
            return Color(c2)
        }

        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))
    }

注意,类似于black,white这样的颜色,它的rgb颜色空间只有两个值,分别表示基于white的值和透明度,其他的颜色的rgb空间有4个值。

说白了,就是根据percent混合from和to的颜色,在本例中,from混合的颜色是greenyellow,to混合的颜色是bluered

完整代码如下:

struct Example11: View {
    @State private var animate = false
    
    var body: some View {
        let gradient1: [UIColor] = [.green, .blue]
        let gradient2: [UIColor] = [.yellow, .red]
        
        return VStack {
            Spacer()
            
            RoundedRectangle(cornerRadius: 15)
                .frame(width: 300, height: 300)
                .modifier(GradientAnimatabelModifier(from: gradient1, to: gradient2, pct: animate ? 1 : 0))
            
            Spacer()
            
            Button("颜色过渡") {
                withAnimation(.easeInOut(duration: 1.0)) {
                    self.animate.toggle()
                }
            }
            
            Spacer()
        }
    }
}

struct GradientAnimatabelModifier: AnimatableModifier {
    let from: [UIColor]
    let to: [UIColor]
    var pct: CGFloat
    
    var animatableData: CGFloat {
        get {
            pct
        }
        set {
            pct = newValue
        }
    }
    
    func body(content: Content) -> some View {
        var gColors = [Color]()
        
        for i in 0..<from.count {
            gColors.append(colorMixer(c1: from[i], c2: to[i], pct: pct))
        }
        
        return RoundedRectangle(cornerRadius: 15)
            .fill(LinearGradient(gradient: .init(colors: gColors), startPoint: .topLeading, endPoint: .bottomTrailing))
            .frame(width: 300, height: 300)
    }

    func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
        let rgbSpace = CGColorSpaceCreateDeviceRGB()
        guard let cc1 = c1.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else {
            return Color(c1)
        }
        guard let cc2 = c2.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else {
            return Color(c2)
        }

        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))
    }
}

3. More Text Animation

这一小节,主要讲解如何实现下边的动画:

很明显,动画是针对字符串中的字符来执行的,基本原理是随着percent的变化,字符的scale随之变化。

因此我们的问题变为如何根据percent计算相应位置的字符的scale?

我们设置一个具有一定宽度的区域,通过计算该区域的字符的位置来计算相应的scale,核心代码如下:

        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
        }

关于上边的代码,做几点说明:

  • chunk表示scale范围的大小,其中waveWidth表示scale的字符数,在本代码中的值为6
  • pct的取值范围是0~1,x的值通过n/total计算出来,因此x的取值范围为0..<1
  • offset指的是pct点右便的距离,它动态变化
  • lowerLimitupperLimit是scale范围的上下限
  • angle表示角度,它的取值范围是-90~-450,因此(sin(angle.rad) + 1) / 2计算的结果范围是0~1

scale范围计算的示意图:

反映到上图中angle的取值范围为B点到A点,因此计算sin(angle.rad)的取值范围正好是-1~1,这里边比较巧妙的是B->C是上升过程,C->A是下降过程,正好符合波形。

大家仔细思考就能够想明白其中奥妙,完整代码如下:

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

struct Example12: View {
    @State private var flag = false
    
    var body: some View {
        VStack {
            Spacer()
            Color.clear.overlay(WaveText("The SwiftUI Lab", waveWidth: 6, pct: flag ? 1.0 : 0.0).foregroundColor(.blue)).frame(height: 40)
            Color.clear.overlay(WaveText("swiftui-lab.com", waveWidth: 6, pct: flag ? 0.0 : 1.0, size: 18).foregroundColor(.green)).frame(height: 30)
            Spacer()
        }.onAppear {
            withAnimation(Animation.easeInOut(duration: 2.0).repeatForever()) {
                self.flag.toggle()
            }
        }.navigationBarTitle("Example 12")
    }
}

struct WaveText: View {
    let text: String
    let pct: Double
    let waveWidth: Int
    var size: CGFloat
    
    init(_ text: String, waveWidth: Int, pct: Double, size: CGFloat = 34) {
        self.text = text
        self.waveWidth = waveWidth
        self.pct = pct
        self.size = size
    }
    
    var body: some View {
        Text(text).foregroundColor(Color.clear).modifier(WaveTextModifier(text: text, waveWidth: waveWidth, pct: pct, size: size))
    }
    
    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
        }
    }
}

4. Getting Creative

类似于这样的counter,看上去实现起来会非常麻烦,但是用AnimatableModifier实现起来就非常easy。记住一点,AnimatableModifier最牛逼的地方在于,能够让我们处理某一个时间点的状态,就好象把一系列的变化定格在某一时刻,我们只关心那一时刻的样式。

这个Counter最核心的想法就是分别计算十位数和个位数在某个数值时的offset。

举个例子,当数字为21时,他的offset为:

可能大家不理解,按理说21正好是整数,offset应该为0啊。其实你想的也没错,但是作者这里的代码是以n+1为基准的,21 + 1 是22, 正好offset为1.

再看一个21.3的例子

21 + 1 = 22 ,显示了22的offset为0.7,再看一个21.8的例子:

相信大家已经明白这个offset是什么意思了,上边演示的是个位数的offset,十位数的offset同理。完整代码如下:

struct Example13: View {
    @State private var number: Double = 21
    
    var body: some View {
        VStack {
            Spacer()
            
            MovingCounter(number: number)
            
            Spacer()
            
            HStack {
                MyButton(label: "35", font: .headline) {
                    withAnimation(Animation.interpolatingSpring(mass: 0.1, stiffness: 1, damping: 0.4, initialVelocity: 0.8)) {
                        self.number = 35
                    }
                }
                
                MyButton(label: "44", font: .headline) {
                    withAnimation(Animation.interpolatingSpring(mass: 0.1, stiffness: 1, damping: 0.4, initialVelocity: 0.8)) {
                        self.number = 44
                    }
                }
                
                MyButton(label: "87", font: .headline) {
                    withAnimation(Animation.interpolatingSpring(mass: 0.1, stiffness: 1, damping: 0.4, initialVelocity: 0.8)) {
                        self.number = 87
                    }
                }
            }
            
            Spacer()
        }
    }
}

struct MovingCounter: View {
    var number: Double
    
    var body: some View {
        Text("00")
        .modifier(CounterAnimatableModifier(number: number))
    }
    
    struct CounterAnimatableModifier: AnimatableModifier {
        var number: Double
        
        var animatableData: Double {
            get {
                number
            }
            set {
                number = newValue
            }
        }
        
        func body(content: Content) -> some View {
            let n = self.number + 1
            
            let uoffset = getOffsetForUnitDigit(n)
            let toffset = getOffsetForTenDigit(n)
            
            let u = [n - 2, n - 1, n, n + 1, n + 2].map{ getUnitDigit($0) }
            let x = getTenDigit(n)
            var t = [abs(x - 2), abs(x - 1), abs(x), 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(CounterShap())
            .overlay(CounterBorder())
            .background(CounterBackground())
        }
        
        func getUnitDigit(_ number: Double) -> Int {
            return abs(Int(number) - (Int(number) / 10) * 10)
        }
        
        func getTenDigit(_ number: Double) -> Int {
            return abs(Int(number) / 10)
        }
        
        func getOffsetForUnitDigit(_ number: Double) -> CGFloat {
            return 1 - CGFloat(number - Double(Int(number)))
        }
        
        func getOffsetForTenDigit(_ number: Double) -> CGFloat {
            if getUnitDigit(number) == 0 {
              return 1 - CGFloat(number - Double(Int(number)))
            }
            return 0
        }
    }
    
    struct ShiftEffect : GeometryEffect {
        var pct: CGFloat = 1.0
        
        func effectValue(size: CGSize) -> ProjectionTransform {
            ProjectionTransform(CGAffineTransform(translationX: 0, y: size.height / 5.0 * pct))
        }
    }
    
    struct CounterShap: Shape {
        func path(in rect: CGRect) -> Path {
            var path = Path()
            
            let h = rect.height / 5.0 + 30
            let r = CGRect(x: 0, y: (rect.height - h) * 0.5, width: rect.width, height: h)
            
            path.addRoundedRect(in: r, cornerSize: CGSize(width: 5.0, height: 5.0))
            
            return path
        }
    }
    
    struct CounterBorder: View {
        var body: some View {
            GeometryReader { proxy in
                RoundedRectangle(cornerRadius: 5.0)
                    .stroke(lineWidth: 5)
                    .foregroundColor(.blue)
                    .frame(width: 80, height: proxy.size.height / 5.0 + 30)
            }
        }
    }
    
    struct CounterBackground: View {
        var body: some View {
            GeometryReader { proxy in
                RoundedRectangle(cornerRadius: 5.0)
                    .fill(Color.black)
                    .frame(width: 80, height: proxy.size.height / 5.0 + 30)
            }
        }
    }
}

5. Animating Text Color

如果我们可以对某个View的foregroundColor执行颜色的渐变动画,但是,当把这个动画放大文本上的时候,就不好使了,利用AnimatableModifier可以轻松实现,这个动画的实现实在是太简单了,我们就不做更多的解释了,直接上代码:

struct Example15: View {
    @State private var flag = false
    
    var body: some View {
        VStack {
            AnimatableColorText(from: .systemRed, to: .systemBlue, pct: flag ? 1 : 0) {
                Text("我是一个好人").font(.largeTitle).bold()
            }
        }
        .onTapGesture {
            withAnimation(.easeInOut(duration: 1.0)) {
                self.flag.toggle()
            }
        }
    }
}

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(.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))
    }
    
    func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
        let rgbSpace = CGColorSpaceCreateDeviceRGB()
        guard let cc1 = c1.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else {
            return Color(c1)
        }
        guard let cc2 = c2.cgColor.converted(to: rgbSpace, intent: .defaultIntent, options: nil)?.components else {
            return Color(c2)
        }

        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的强大之处在于他即遵守了Animatable协议,又是一个ViewModifier,因此我们可以根据animatableData来返回一个View。这就像把一段连续的动画,打散成一张张的图片。

注:上边的内容参考了网站https://swiftui-lab.com/swiftui-animations-part3/