高级 SwiftUI 动画 — Part 1:Paths

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

前言

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

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

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

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

我们将像这样使用它:

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 后,一切都会变得不同。

在模拟器上运行时,你可能感觉不到有什么不同。然而,在真正的设备上,你会发现。视频演示来自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

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

本文已在公众号「Swift社区」发布,如果转载长白请加微信:fzhanfei,备注转载长白

目录
相关文章
webpack优化篇(四十):速度分析:使用 speed-measure-webpack-plugin
webpack优化篇(四十):速度分析:使用 speed-measure-webpack-plugin
998 0
webpack优化篇(四十):速度分析:使用 speed-measure-webpack-plugin
|
1月前
|
存储 Swift
大师学SwiftUI第18章Part3 - 自定义视频播放器
录制和播放视频对用户来说和拍照、显示图片一样重要。和图片一样,Apple框架中内置了播放视频和创建自定义播放器的工具。
167 0
|
存储 C# Python
基于C#的ArcEngine二次开发46:编辑内容回撤与炸开multipart feature
基于C#的ArcEngine二次开发46:编辑内容回撤与炸开multipart feature
基于C#的ArcEngine二次开发46:编辑内容回撤与炸开multipart feature
|
Java iOS开发 MacOS
高级 SwiftUI 动画 — Part 3:AnimatableModifier
之前的两篇文章animating paths 和 transform matrices 对 Animatable 协议使用做了介绍,今天这篇文章将为大家介绍 AnimatableModifier,使用它可以完成更多的动画工作。
119 0
高级 SwiftUI 动画 — Part 2:GeometryEffect
在本系列的第一部分,我介绍了Animatable协议,以及我们如何使用它来为路径制作动画。接下来,我们将使用一个新的工具: GeometryEffect,用同样的协议对变换矩阵进行动画处理。如果你没有读过第一部分,也不知道Animatable协议是什么,你应该先读一下。或者如果你只是对GeometryEffect感兴趣,不关心动画,你可以跳过第一部分,继续阅读本文。
128 0
Flutter基础widgets教程-ExpansionPanel篇
Flutter基础widgets教程-ExpansionPanel篇
187 0
SAP Spartacus B2B Unit page Expand all按钮的工作原理
SAP Spartacus B2B Unit page Expand all按钮的工作原理
104 0
SAP Spartacus B2B Unit page Expand all按钮的工作原理
|
Web App开发 开发者
SAP Spartacus org unit页面的三种focus border及细节讨论
SAP Spartacus org unit页面的三种focus border及细节讨论
SAP Spartacus org unit页面的三种focus border及细节讨论
SAP Spartacus unit detail 页面显示后自动 focus 设置的原理
这个自动 focus 设置的效果是:我们从 Spartacus Unit list 页面,随便选择一行,进入明细页面之后:
SAP Spartacus unit detail 页面显示后自动 focus 设置的原理
SAP Spartacus My Company list focus事件触发后,控件border的默认效果
SAP Spartacus My Company list focus事件触发后,控件border的默认效果
SAP Spartacus My Company list focus事件触发后,控件border的默认效果