SwiftUI动画进阶 - Part3 AnimatableModifier(动画修饰器)

1,485 阅读7分钟

文章源地址:swiftui-lab.com/swiftui-ani…

作者: Javier

翻译: Liaoworking

我们已经知道了Animatable协议是如何帮助我们来让path做动画变换矩阵,在本系列的最后一个部分,我们将更近一步。AnimatableModifier 是这三个工具中最强大的一个。有了它你就可以为所欲为了。

从命名上来看AnimatableModifier(可动修饰器),这是一个遵循Animatable协议(第一节里讲的)视图修饰器,如果你不知道Animatable和animatableData 怎么工作的,可以回去第一节再看看。

现在可以先想想使用animatable modifier(可动修饰器)有什么作用,你可以通过它类多次修改你的视图来做动画。

The complete sample code for this article can be found at: gist.github.com/swiftui-lab…

Example8 requires images from an Asset catalog. Download it from here: swiftui-lab.com/?smd_proces…

AnimatableModifier为啥做不了动画了?

如果打算在生产环境使用AnimatableModifier,那你一定要阅读最后一节,和版本做斗争

如果你想要尝试一下协议,机会来了,你可能马上就要碰壁了。我之前已经尝试过了,我写了一个很简单的animatable modifier,但是视图并没有做动画,我又做了一些其他的尝试,还是不行,幸运的是 我坚持了一会,成功了。 先把这个幸运的是加粗。 我的第一个modifier很好,但是当它在容器内部的时候就不起作用了。。。 第二次起作用是因为我的视图不在容器内部,如果我一开始就很幸运,就不会写第三篇文章了。

例如下面这个modifier就可以很好的做动画

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

但是在VStack中,一样的代码就不会生效

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

那么如何在VStack中让animatable modifiers起作用呢?我们可以用下面这个取巧的方法:

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

先用一个透明的视图来占位,让后在透明的图上面使用.overlay()去添加实际的图。我们需要知道实际图的大小,来确定透明图的大小,这一点有时会会麻烦一些。

我把这个问题报告给苹果了,点击这里查询FB代码。你也可以试一试。

文字动画:

第一个例子是做一个加载指示器。

image

第一直觉告诉我应该使用animatable path,然而这个并不能让label做动画,那么用AnimatableModifier试试。

完整的代码在顶部的gist中的 Example10 可以找到。

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

正如你再例子中所看到的,我们并没有让弧形动起来,这并不是必须的,因为modifier已经多次通过不同的百分比pct去创建图形了。

渐变动画

如果你想要让一个渐变层做动画。就好发现有很多限制,例如你可以从起点运动到终点,但是你不能让渐变色改变,但在AnimatableModifier中就可以实现:

image

实现起来比较简单,我们只需要计算RGB的平均值。不过要注意modifier 假定我们从头到尾每一个输入的颜色数组的count是相同的。

完整代码可以从文章顶部的gist的 Example11 中找到。

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..<from.count {
            gColors.append(colorMixer(c1: from[i], c2: to[i], pct: pct))
        }
        
        return RoundedRectangle(cornerRadius: 15)
            .fill(LinearGradient(gradient: Gradient(colors: gColors),
                                 startPoint: UnitPoint(x: 0, y: 0),
                                 endPoint: UnitPoint(x: 1, y: 1)))
            .frame(width: 200, height: 200)
    }
    
    // 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))
    }
}

更多的文字动画

在我们下面的例子中我们将只一次只给一个字母做动画。

image

平滑的逐步缩放需要一些数学运算。如果写出来就乐在其中了。代码我放在了 文章顶部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有所了解之前,下面的计数器可能有一点挑战性。

image

这个练习的取巧之处就每一列拿了五个数字竖向排列,并用了.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()做动画,就会发现开发者体验极好,完整代码在 Example14 中了。

image

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

    }
}

Dancing With Versions(和版本做斗争)

我们已经发现了AnimatableModifier很强大了,虽然也稍微有点bug。最大的问题是在一些具体的Xcode and iOS、macOS 版本下面应用会再启动的时候崩溃了,更严重的是在部署的时候更频繁。但是编译和在dev环境的时候就没事。以为会没啥问题,但在部署的时候去编译就会有下面的内容:

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

例如 Xcode11.3在macOS 10.15.0上执行就取法启动 并显示”找不到符号表“的错误,但在10.15.1上相同的文件就稳得一批。

相反,如果在Xcode11.1上去部署,就在所有的macOS版本上正常(至少我试过的版本)

iOS系统也会有类似的问题, Xcode 11.2打包使用AnimatableModifier的应用无法在iOS 13.2.2上启动,但在iOS 13.2.3上可以正常运行。

所以我暂时都是求稳用的Xcode11.1。以后可能会使用较新的版本,不过会把Mac系统版本提升到10.15.1(除非把这个bug修了,不过我深表怀疑。。)

总结和接下来要讲什么

我们已经看到了Animatable协议的简单使用。发挥您的创造力,会有很多炫酷的动画。

到此"SwiftUI 高级动画" 系列就全结束了,下面我会讲一些关于自定义转场的文字。也算是对这几篇文章做一个总结了。

可以在Twitter上关注我来确保获取更多的内容。 欢迎评论。如果你想有新的文章出来的时候收到提醒,下面有链接。 swiftui-lab.com/