[译]高级 SwiftUI 动画 — Part 1:Paths

在本文中,我们将深入探讨一些创建 SwiftUI 动画的高级技术。我将广泛讨论 Animatable 协议,它可靠的伙伴 animatableData,强大但经常被忽略的 GeometryEffect 以及完全被忽视但全能的 AnimatableModifier 协议。

这些都是被官方文档完全忽略的主题,在SwiftUI 的帖子和文章中也几乎没有提及。不过,它们还是为我们提供了创建一些相当不错的动画的工具。

在我们进入这些隐藏的瑰宝之前,我想对一些基本的 SwiftUI 动画概念做一个非常快速的总结。只是为了让我们能有共同语言,请耐心听我说。

本文的完整示例代码可在以下网址找到:
https://gist.github.com/swiftui-lab/e5901123101ffad6d39020cc7a810798

示例8 需要的图片资源,可在这里下载:
https://swiftui-lab.com/?smd_process_download=1&download_id=916

显式动画 VS 隐式动画

在SwiftUI中,有两种类型的动画。显式和隐式。隐式动画是你用 .animation() 修饰符指定的那些动画。每当视图上的可动画参数发生变化时,SwiftUI 就会从旧值到新值制作动画。一些可动画的参数包括大小(size)、偏移(offset)、颜色(color)、比例(scale)等。

显式动画是使用 withAnimation{ … } 指定的动画闭包。只有那些依赖于 withAnimation 闭包中改变值的参数才会被动画化。让我们尝试举一些例子来说明:

以下示例使用隐式动画更改图像的大小和不透明度:

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 闭包中唯一更改的参数:

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()
                }
        }
    }
}
显式动画

请注意,通过更改修饰符的前后顺序,可以使用隐式动画创建相同的效果:

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

为什么我关心 Animatable

你可能会问,为什么我需要关心所有这些小细节。SwiftUI 已经为不透明度制作了动画,而不需要我担心这一切。是的,这是真的,但只要 SwiftUI 知道如何将数值从原点插值到终点。对于不透明度,这是一个直接的过程,SwiftUI 知道该怎么做。然而,正如我们接下来要看到的,情况并非总是如此。

我想到了一些大的例外情况:路径(paths)、变换矩阵(matrices)和任意的视图变化(例如,文本视图中的文本、渐变视图中的渐变颜色或停顿,等等)。在这种情况下,框架不知道该怎么做。我们将在本文的第二和第三部分中讨论转换矩阵和视图变化。目前,让我们把重点放在形状(shapes)上。

形状路径的动画化

想象一下,你有一个形状,使用路径来绘制一个规则的多边形。我们的实现当然会让你指出这个多边形将有多少条边。

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

下面是我们的PolygonShape的实现。请注意,我使用了一点三角学的知识。这对理解这篇文章的主题并不重要,但如果你想了解更多关于它的信息,我写了另一篇文章,阐述了基础知识。你可以在 "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
    }
}

我们可以更进一步,尝试使用与不透明度相同的方法对形状边数(sides)参数进行动画处理:

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

你认为 SwiftUI 将如何把三角形转化为正方形?你可能猜到了。它不会的。当然,框架不知道如何给它做动画。你可以随心所欲地使用.animation(),但这个形状会从三角形跳到正方形,而且没有任何动画。原因很简单:你只教了 SwiftUI 如何画一个 3 边的多边形,或 4 边的多边形,但你的代码却不知道如何画一个 3.379 边的多边形!

因此,为了使动画发生,我们需要两件事:

  1. 我们需要改变形状的代码,使其知道如何绘制边数为非整数的多边形。

  2. 让框架多次生成这个形状,并让可动画参数一点点变化。也就是说,我们希望这个形状被要求绘制多次,每次都有一个不同的边数数值:3、3.1、3.15、3.2、3.25,一直到 4。

一旦我们把这两点做到位,我们将能够在任何数量的边数之间制作动画:

多边形动画 一

)

创建可动画数据(animatableData)

为了使形状可动画化,我们需要 SwiftUI 多次渲染视图,使用从原点到目标数之间的所有边值。幸运的是,Shape已经符合了Animatable协议的要求。这意味着,有一个计算的属性(animatableData),我们可以用它来处理这个任务。然而,它的默认实现被设置为EmptyAnimatableData。所以它什么都不做。

为了解决我们的问题,我们将首先改变边的属性的类型,从IntDouble。这样我们就可以有小数的数字。我们将在后面讨论如何保持该属性为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 如何绘制一个边数为非整数的多边形。我们将稍微改变我们的代码。随着小数部分的增长,这个新的边将从零到全长。其他顶点将相应地平稳地重新定位。这听起来很复杂,但这是一个最小的变化。

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

            // Calculate vertex
            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
    }

完整的代码可在文章顶部链接的 gist 文件中以 Example1 的形式提供。

如前所述,对于我们这个形状的用户来说,边的参数是一个Double,这可能显得很奇怪。人们应该期望边是一个Int参数。幸运的是,我们可以再次改变我们的代码,把这个事实隐藏在我们的形状的实现中:

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

    ...
}

有了这些变化,我们在内部使用Double,但在外部则使用Int。现在它看起来更优雅了。不要忘记修改绘图代码,这样它就会使用sidesAsDouble 而不是sides。完整的代码可以在文章顶部链接的 gist 文件中的 Example2 中找到。

设置多个参数的动画

很多时候,我们会发现自己需要对一个以上的参数进行动画处理。单一的Double是不够的。在这些时候,我们可以使用AnimatablePair<First, Second>。这里,第一和第二都是符合VectorArithmetic的类型。例如AnimatablePair<CGFloat, Double>

多边形动画 二

)

为了演示 AnimatablePair 的使用,我们将修改我们的例子。现在我们的多边形形状将有两个参数:边和比例。两者都将用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 文件中的 Example3 中找到。同一个文件中的Example4,有一个更复杂的路径。它基本上是相同的形状,但增加了一条连接每个顶点的线。

多边形动画 四

超过两个可动画的参数

如果你浏览一下 SwiftUI 的声明文件,你会发现该框架相当广泛地使用AnimatablePair。比如说。CGSizeCGPointCGRect。尽管这些类型不符合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、宽度和高度值可以通过 first.firstfirst.secondsecond.firstsecond.second访问。

使你自己的类型动画化(通过VectorArithmetic

以下类型默认实现了 Animatable : Angle, CGPoint, CGRect, CGSize, EdgeInsets, StrokeStyleUnitPoint。以下类型符合VectorArithmeticAnimatablePair, CGFloat, Double, EmptyAnimatableDataFloat。你可以使用它们中的任何一种来为你的形状制作动画。

现有的类型提供了足够的灵活性来实现任何东西的动画。然而,如果你发现自己有一个想做动画的复杂类型,没有什么能阻止你添加自己的VectorArithmetic协议的实现。事实上,我们将在下一个例子中这样做。

为了说明这一点,我们将创建一个模拟时钟形状。它将根据一个自定义的可动画的参数类型移动它的指针:ClockTime

ClockTime

我们将像这样使用它:

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

首先,我们开始创建我们的自定义类型ClockTime。它包含三个属性(小时、分钟和秒),几个有用的初始化器,以及一些辅助计算的属性和方法。

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

在模拟器上运行时,你可能感觉不到有什么不同。然而,在真正的设备上,你会发现。视频演示来自iPad第六代(2016)。完整的代码在 gist 文件中,名称为 Example6

幸运的是,启用 Metal,是非常容易的。你只需要添加 .drawingGroup() 修饰符:

FlowerView().drawingGroup()

根据 WWDC 2019, Session 237(用SwiftUI构建自定义视图):绘图组是一种特殊的渲染方式,但只适用于图形等东西。它基本上会将 SwiftUI 视图平铺到一个单一的 NSView/UIView 中,并用 Metal 进行渲染。跳到 WWDC 视频到37:27 了解更多细节。

如果你想尝试一下,但你的形状还没有复杂到让设备挣扎的地步,添加一些渐变和阴影,你会立即看到不同。

接下来有什么内容?

在本文的第二部分,我们将学习如何使用 GeometryEffect 协议。它将打开改变我们的视图和动画的新方法的大门。与 Paths 一样,SwiftUI 没有关于如何在两个不同的变换矩阵之间转换的内置知识。GeometryEffect将有助于我们这样做。

目前,SwiftUI 没有关键帧功能。我们将看到我们如何用一个基本的动画来模拟一个。

在文章的第三部分,我们将介绍AnimatableModifier,这是一个非常强大的工具,它可以让我们对视图中任何可以变化的东西进行动画处理,甚至是文本!在这个系列的第三部分中,我们将介绍一些动画实例。关于这三部分系列中的一些动画例子,请看下面的视频:

https://swiftui-lab.com/wp-content/uploads/2019/08/animations.mp4

译自 The SwiftUI LabAdvanced SwiftUI Animations – Part 1: Paths

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容