14

高级 SwiftUI 动画 — Part 1:Paths

 2 years ago
source link: https://mp.weixin.qq.com/s/5KinQfNtcovf_451UGwLQQ
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

高级 SwiftUI 动画 — Part 1:Paths

Original Swift君 Swift社区 2022-02-14 01:12
收录于话题
#SwiftUI 19 个
#iOS 55 个
#Tips 72 个

👇👇回复 “进群” 拉你进社区交流群👇👇

Swift社区
做最好的 Swift 社区,我们的使命是做一个最专业最权威的 Swift 中文社区,我们的愿景是希望更多的人学习和使用Swift。我们会分享以 Swift 实战、SwiftUI、Swift 基础为核心的技术干货,不忘初心,牢记使命。
109篇原创内容
Official Account

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

这些都是被官方文档完全忽略的主题,在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()
            }
    }
}
640?wx_fmt=gif

下面的示例使用显式动画。在这里,缩放和不透明度都会更改,但只有不透明度会设置动画,因为它是 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()
                }
        }
    }
}
640?wx_fmt=gif

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

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)
640?wx_fmt=png

下面是我们的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。

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

640?wx_fmt=gif

创建可动画数据(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>

640?wx_fmt=gif

为了演示 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,有一个更复杂的路径。它基本上是相同的形状,但增加了一条连接每个顶点的线。

640?wx_fmt=gif

超过两个可动画的参数

如果你浏览一下 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

640?wx_fmt=gif

我们将像这样使用它:

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

640?wx_fmt=gif

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

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

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

[1]

Animatable: https://developer.apple.com/documentation/swiftui/animatable

[2]

animatableData: https://developer.apple.com/documentation/swiftui/animatable/3046497-animatabledata

[3]

GeometryEffect: https://developer.apple.com/documentation/swiftui/geometryeffect

[4]

AnimatableModifier: https://developer.apple.com/documentation/swiftui/animatablemodifier


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK