[SwiftUI-Lab]SwiftUI动画进阶 - Part1 Paths

3,540 阅读11分钟

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

作者: Javier

翻译: Liaoworking

本文我们将要深度探究一下SwiftUI的动画,也会广泛的讨论 Animatable protocol和其经常一起出现的animatableData, 功能强大但经常被忽略的GeometryEffect, 还有完全被忽略但强大的AnimatableModifier协议。

这些内容在官方文档中都没有写,在SwiftUI相关的文章中也没有怎么提及。不过苹果爸爸还是提供给我们一些创建炫酷动画的工具。

在我们探究宝藏之前,先对本页的SwiftUI动画概念做一个很快的总结。稍待片刻。

完整代码在此:
[https://gist.github.com/swiftui-lab/e5901123101ffad6d39020cc7a810798](https://gist.github.com/swiftui-lab/e5901123101ffad6d39020cc7a810798)

例子8所需要的图片和其他资源从这里下载:
[https://swiftui-lab.com/?smd_process_download=1&download_id=916](https://swiftui-lab.com/?smd_process_download=1&download_id=916)

显式和隐式动画

SwiftUI中有两种对动画类型,显式和隐式 隐式就是你用.animation()指定的动画,无论View哪个可以做动画的参数改变了,就会有动画效果。如:size, offset, color, scale等。

显式动画就是用withAnimation { ... }闭包修饰的,只有闭包中的可动参数改变了才会执行动画,来几个例子演示一下。

下面的例子用的隐式动画来改变图片的大小和透明度。

Implicit Animation

struct Example1: View {
    @State private var half = false
    @State private var dim = false
    
    var body: some View {
        Image("tower")
            .scaleEffect(half ? 0.5 : 1.0)
            .opacity(dim ? 0.2 : 1.0)
            .animation(.easeInOut(duration: 1.0))
            .onTapGesture {
                self.dim.toggle()
                self.half.toggle()
            }
    }
}

下面的例子就是显示动画,透明度和缩放都改变的时候,但只有透明度会做动画,因为只有这一个参数在withAnimation闭包中。

Explicit Animation

struct Example2: View {
    @State private var half = false
    @State private var dim = false
    
    var body: some View {
        Image("tower")
            .scaleEffect(half ? 0.5 : 1.0)
            .opacity(dim ? 0.5 : 1.0)
            .onTapGesture {
                self.half.toggle()
                
                withAnimation(.easeInOut(duration: 1.0)) {
                    self.dim.toggle()
                }
        }
    }
}

注意,我们可以通过隐式动画来达到同样的效果,只要改变修改器(modifiers)的位置即可。

struct Example2: View {
    @State private var half = false
    @State private var dim = false
    
    var body: some View {
        Image("tower")
            .opacity(dim ? 0.2 : 1.0)
            .animation(.easeInOut(duration: 1.0))
            .scaleEffect(half ? 0.5 : 1.0)
            .onTapGesture {
                self.dim.toggle()
                self.half.toggle()
        }
    }
}

你如果想要关闭动画,可以使用.animation(nil)

动画是怎么实现的

在所有的SwiftUI动画背后,都有一个叫做Animatable的协议,它包括一个遵循VectorArithmetic(矢量运算)协议的计算型属性。这使得系统可以随意插值。

当一个视图做动画的时候,SwiftUI已经很多次的生成视图了。每次都会修改动画参数,这样动画参数就可以从原始值逐渐变成最终值。

假设我们让一个视图的透明度做线性动画,从0.3到0.8, 系统就会很多次的生成新的视图,一点一点增加透明度,由于透明度是Double类型的,而且Double遵循了VectorArithmetic协议,SwiftUI就能插入所需要的透明度。在系统的某处,可能就会有下面的计算方法。

let from:Double = 0.3
let to:Double = 0.8

for i in 0..<6 {
    let pct = Double(i) / 5
    
    var difference = to - from
    difference.scale(by: pct)
    
    let currentOpacity = from + difference
    
    print("currentOpacity = \(currentOpacity)")
}

这段代码会逐渐将值从原始值改变到最终值。

currentOpacity = 0.3
currentOpacity = 0.4
currentOpacity = 0.5
currentOpacity = 0.6
currentOpacity = 0.7
currentOpacity = 0.8

为什么关注动画?

你可能想知道,为什么会关注这些细节。SwiftUI设置不透明的动画,就是改变不透明度,就只是简单的在初始值和最终值之间插值,但是我们接下来看到的并非这么简单。

先说几个比较大的概念:path(路径)、transform matrices(矩阵变换) 和 arbitrary view changes(任意视图更改, 例如文本框中的文字,渐变中的颜色数组或者中转点 等)。在这个例子中,系统并不知道要做什么,没有任何关于A到B的提前预行为,我们在第二第三部分将要讨论矩形变换和视图变化, 现在先关注一下shapes(形状)

动画的形状路径

假如你有一个多边形,是由path(路径)绘制出来的,我们可以实现一个指定多边形的图形

PolygonShape(sides: 3).stroke(Color.blue, lineWidth: 3)
PolygonShape(sides: 4).stroke(Color.purple, lineWidth: 4)

image

下面是多边形的实现, 我用了一些三角学的知识,并不是本文需要必须掌握的,但如果你想要学习的话,你可以在我的"SwiftUI中的三角学"中了解更多。

struct PolygonShape: Shape {
    var sides: Int
    
    func path(in rect: CGRect) -> Path {        
        // hypotenuse
        let h = Double(min(rect.size.width, rect.size.height)) / 2.0
        
        // center
        let c = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0)
        
        var path = Path()
                
        for i in 0..<sides {
            let angle = (Double(i) * (360.0 / Double(sides))) * Double.pi / 180

            // Calculate vertex position
            let pt = CGPoint(x: c.x + CGFloat(cos(angle) * h), y: c.y + CGFloat(sin(angle) * h))
            
            if i == 0 {
                path.move(to: pt) // move to first vertex
            } else {
                path.addLine(to: pt) // draw line to next vertex
            }
        }
        
        path.closeSubpath()
        
        return path
    }
}

我们可以更近一步,尝试和之前透明度变化动画相同的使用方法。

PolygonShape(sides: isSquare ? 4 : 3)
    .stroke(Color.blue, lineWidth: 3)
    .animation(.easeInOut(duration: duration))

你觉得SwiftUI是怎样将三角形转化成四边形的,系统其实也没有思路去做动画,你可能碰到动画就.animation() 但是这里三角形只会马上跳到四边形。原因很简单,SwiftUI知道如何绘制三角形和四边形,但是它不知道如何绘制3.379边形。

所以,想要有动画效果,我们需要两件东西:

1.我们需要去改变Shape相关的代码,因为它知道如何绘制非整数边形。 2.让系统通过增加可动参数去多次生成形状,我们希望形状被绘制多次,每次都有不同的边数值:3, 3.1, 3.15, 3.2, 3.25 一直到4.

一旦我们做到了这些,我们就可以做到任何边数中间的动画切换。

生成动画数据

想让图形动起来,我们需要SwiftUI使用初始值到最终值之间的所有值来多次渲染视图,幸运的是,Shape(图形)已经遵守了Animatable协议,这意味着我们可以用它们的计算型属性animatableData来处理。 默认它是实现的,但只是设置的是EmptyAnimatableData, 啥也没做。

为了解决我们的问题,先把Sides从Int改成Double类型,这样我们就有了小数类型,后面再讨论怎么把这个属性维护成Int类型,还可以做动画,这里为了简单先改成Double。

struct PolygonShape: Shape {
    var sides: Double
    ...
}

这时候我们再创建计算型属性 animatableData, 这里就很简单了

struct PolygonShape: Shape {
    var sides: Double

    var animatableData: Double {
        get { return sides }
        set { sides = newValue }
    }

    ...
}

用小数去画多边形的边

最后,我们需要告诉SwiftUI怎么用小数去画多边形的边,我们将稍微改变一下我们的代码,当小数部分增长的时候,新的边将会从0变成到真正的长度,其他顶点将相应的平滑定位。听起来很复杂,不过只是稍微改动一下代码。

func path(in rect: CGRect) -> Path {
        
        // 斜边
        let h = Double(min(rect.size.width, rect.size.height)) / 2.0
        
        // 中心
        let c = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0)
        
        var path = Path()
                
        let extra: Int = Double(sides) != Double(Int(sides)) ? 1 : 0

        for i in 0..<Int(sides) + extra {
            let angle = (Double(i) * (360.0 / Double(sides))) * Double.pi / 180

            // 计算顶点
            let pt = CGPoint(x: c.x + CGFloat(cos(angle) * h), y: c.y + CGFloat(sin(angle) * h))
            
            if i == 0 {
                path.move(to: pt) // 移动到第一个顶点
            } else {
                path.addLine(to: pt) // 画到下一个顶点的线
            }
        }
        
        path.closeSubpath()
        
        return path
    }

完整的代码写在了文章顶部的gist file中例1中。

像之前提及到的,好像边长数为Double会很奇怪,按道理应该是Int类型的,幸运的是,我们可以在Shape的实现中完善一下代码。

struct PolygonShape: Shape {
    var sides: Int
    private var sidesAsDouble: Double
    
    var animatableData: Double {
        get { return sidesAsDouble }
        set { sidesAsDouble = newValue }
    }
    
    init(sides: Int) {
        self.sides = sides
        self.sidesAsDouble = Double(sides)
    }

    ...
}

这样的话,我们外面使用的是Int,内部使用的是Double, 现在看起来就更优雅一些了,用sidesAsDouble来代替sides,完整的代码在文章顶部的gist中的 Example2 里。

不止一个参数的动画

我们经常会发现需要给不止一个参数设置动画,我们可以使用AnimatablePair<First, Second> ,这里的FirstSecond都要遵循VectorArithmetic,例如AnimatablePair<CGFloat, Double>.

image

为了展示AnimatablePair的使用,对例子稍加改造,现在我们的多边形将有两个参数:sidesscale。两个都用Double来表示。

 struct PolygonShape: Shape {
    var sides: Double
    var scale: Double
    
    var animatableData: AnimatablePair<Double, Double> {
        get { AnimatablePair(sides, scale) }
        set {
            sides = newValue.first
            scale = newValue.second
        }
    }

    ...
}

完整代码可以在文章顶部的gist中的例3中找到,例4也在里面,甚至有更复杂的path,它们的形状相对,但是多了一条线,把每个顶点相互连接起来。

两个以上的动画参数

如果你有翻阅SwiftUI的声明文件,你就会发现框架中很多地方都用到了AnimatablePair,例如CGSize,CGPoint,CGRect. 虽然这些类型都没有遵守VectorArithmetic,不过他们都可以做动画,因为它们遵循了Animatable协议。

它们都在以一种或者多种形式使用AnimatablePair

     extension CGPoint : Animatable {
        public typealias AnimatableData = AnimatablePair<CGFloat, CGFloat>
        public var animatableData: CGPoint.AnimatableData
    }
    
    extension CGSize : Animatable {
        public typealias AnimatableData = AnimatablePair<CGFloat, CGFloat>
        public var animatableData: CGSize.AnimatableData
    }
    
    extension CGRect : Animatable {
        public typealias AnimatableData = AnimatablePair<CGPoint.AnimatableData, CGSize.AnimatableData>
        public var animatableData: CGRect.AnimatableData
    }

如果你仔细观察CGRect,你就会发现实际上它也有在使用:

AnimatablePair<AnimatablePair<CGFloat, CGFloat>, AnimatablePair<CGFloat, CGFloat>>

这也就意味着 矩形的x,y,width,height的值都可以通过first.first, first.second, second.firstsecond.second 来获得。

让你自己的类型可动(使用矢量运算)

下面这些类型都遵循 Animatable: Angle(角), CGPoint, CGRect, CGSize, EdgeInsets, StrokeStyle(线型) 和 UnitPoint(单位点)。 下面这些类型都遵循的 矢量计算:AnimatablePair(动画配对), CGFloat, Double, EmptyAnimatableData(空白动画数据) 和 Float。 你可以运用上面的类型来让你的图形做动画。

现有的类型已经足够有足够的灵活性来支持任何动画了,------------------

为了说明这一点,我们创建一个闹钟的样子,它将根据它的可变参数类型:ClockTime来移动它的指针。

最终会以这样使用:

ClockShape(clockTime: show ? ClockTime(9, 51, 15) : ClockTime(9, 55, 00))
    .stroke(Color.blue, lineWidth: 3)
    .animation(.easeInOut(duration: duration))

我们先创建我们自定义类型 ClockTime. 它包括三个属性(hours, minutes and seconds),一些有用的初始化方法,和一些计算型属性和方法。

struct ClockTime {
    var hours: Int      // Hour needle should jump by integer numbers
    var minutes: Int    // Minute needle should jump by integer numbers
    var seconds: Double // Second needle should move smoothly
    
    // Initializer with hour, minute and seconds
    init(_ h: Int, _ m: Int, _ s: Double) {
        self.hours = h
        self.minutes = m
        self.seconds = s
    }
    
    // Initializer with total of seconds
    init(_ seconds: Double) {
        let h = Int(seconds) / 3600
        let m = (Int(seconds) - (h * 3600)) / 60
        let s = seconds - Double((h * 3600) + (m * 60))
        
        self.hours = h
        self.minutes = m
        self.seconds = s
    }
    
    // compute number of seconds
    var asSeconds: Double {
        return Double(self.hours * 3600 + self.minutes * 60) + self.seconds
    }
    
    // show as string
    func asString() -> String {
        return String(format: "%2i", self.hours) + ":" + String(format: "%02i", self.minutes) + ":" + String(format: "%02f", self.seconds)
    }
}

现在为了遵循 VectorArithmetic 协议,我们需要写如下的方法和计算型属性。

extension ClockTime: VectorArithmetic {
    static var zero: ClockTime {
        return ClockTime(0, 0, 0)
    }

    var magnitudeSquared: Double { return asSeconds * asSeconds }
    
    static func -= (lhs: inout ClockTime, rhs: ClockTime) {
        lhs = lhs - rhs
    }
    
    static func - (lhs: ClockTime, rhs: ClockTime) -> ClockTime {
        return ClockTime(lhs.asSeconds - rhs.asSeconds)
    }
    
    static func += (lhs: inout ClockTime, rhs: ClockTime) {
        lhs = lhs + rhs
    }
    
    static func + (lhs: ClockTime, rhs: ClockTime) -> ClockTime {
        return ClockTime(lhs.asSeconds + rhs.asSeconds)
    }
    
    mutating func scale(by rhs: Double) {
        var s = Double(self.asSeconds)
        s.scale(by: rhs)
        
        let ct = ClockTime(s)
        self.hours = ct.hours
        self.minutes = ct.minutes
        self.seconds = ct.seconds
    }    
}

最后要做的就是正确定位指针的位置,完整代码在文章顶部gist的Example5里面。

SwiftUI + Metal

在创建复杂动画的时候可能会发现稍微会有一些卡顿,运用Metal可以让你解决这个问题。 这里有一个例子来看Metal到底有多流畅。

模拟器上可能感觉不到差压,但在真机上可能会感觉到,视频是从2016版iPad上录制的,完整的代码你可以在gist的 Example6中找到。

幸运的是很简单就可以使用Metal, 你只需要在后面添加一个.drawingGroup() 修饰器。

FlowerView().drawingGroup()

在WWDC2019 Session 237中(Building Custom Views with SwiftUI)中讲到:绘图组是一个特殊的仅限于图形的渲染方式。 它可以把SwiftUI视图展平成单个NSView/UIView 并用Metal去渲染,你可以直接跳转到WWDC视频的37:27来获得一些更多细节。

如果你也想尝试一下,但是你的动画还不够复杂,还不能出现轻微的卡顿,可以加一个渐变和阴影,就会马上发现不同。

下面要讲什么

在系列文章的第二部分,我们将要学习到怎么用GeometryEffect协议,这将给你打开一扇新的大门来改变视图和做动画。和之前学的Paths一样。SwiftUI也没有任何关于不同矩阵转换的说明。(GeometryEffect)几何效果将会十分有用。

目前SwiftUI还没有关键帧的功能,我们即将看到如何通过基本动画来模拟出来。

在文字的第三部分,我们将介绍AnimatableModifier(动画修饰器),这是个非常强大的功能,可以让视图中任何改变以动画的形式呈现,甚至是Text!,三篇文章的动画内容都在下面的视频中了。

高级SwiftUI动画

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