swiftui之深入解析高级动画的路径paths(代码片段)

Serendipity·y Serendipity·y     2022-12-01     466

关键词:

一、前言

  • 本文将深入探讨一些创建 SwiftUI 动画的高级技术,讨论 Animatable 的协议,它可靠的伙伴 AnimatableData,强大但经常被忽略的 GeometryEffect 以及完全被忽视但全能的 AnimatableModifier 协议。
  • 这些都是被官方文档完全忽略的主题,在 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)和任意的视图变化(例如,文本视图中的文本、渐变视图中的渐变颜色或停顿等),在这种情况下,框架不知道该怎么做。

① 形状路径的动画化

  • 想象一下,有一个形状,使用路径来绘制一个规则的多边形,实现当然会需要指出这个多边形将有多少条边:
PolygonShape(sides: 3).stroke(Color.blue, lineWidth: 3)
PolygonShape(sides: 4).stroke(Color.purple, lineWidth: 4)

  • 如下所示,是 PolygonShape 的实现,代码中使用了一点三角学的知识:
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 边这样的多边形。
  • 因此,为了使动画发生,需要两件事:
    • 需要改变形状的代码,使其知道如何绘制边数为非整数的多边形;
    • 让框架多次生成这个形状,并让可动画参数一点点变化,也就是说,希望这个形状被要求绘制多次,每次都有一个不同的边数数值:3、3.1、3.15、3.2、3.25,一直到 4。
  • 一旦把这两点做到位,就能够在任何数量的边数之间制作动画:

② 创建可动画数据(animatableData)

  • 为了使形状可动画化,需要 SwiftUI 多次渲染视图,使用从原点到目标数之间的所有边值。幸运的是,Shape 已经符合了 Animatable 协议的要求,这意味着,有一个计算的属性(animatableData),可以用它来处理这个任务。然而,它的默认实现被设置为 EmptyAnimatableData,所以它什么都不做。
  • 为了解决我们的问题,首先改变边的属性的类型,从 Int 到 Double,这样就可以有小数的数字,这里为了使事情简单,只使用 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
    
  • 如前所述,对于这个形状的用户来说,边的参数是一个 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。

④ 设置多个参数的动画

  • 很多时候,我们会发现自己需要对多个参数进行动画处理,单一的 Double 是不够的,在这个时候,可以使用 AnimatablePair<First, Second>。这 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
        
    

    ...

  • 有一个更复杂的路径,它基本上是相同的形状,但增加了一条连接每个顶点的线:

⑤ 超过两个动画参数

  • 如果浏览一下 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、宽度和高度值可以通过 first.first、first.second、second.first 和 second.second 访问。

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

  • Angle、CGPoint、CGRect、CGSize、EdgeInsets、StrokeStyle 和 UnitPoint 等类型都默认实现了 Animatable,AnimatablePair、CGFloat、Double、EmptyAnimatableData 和 Float 符合 VectorArithmetic,我们可以使用它们中的任何一种来为形状制作动画。
  • 现有的类型提供了足够的灵活性来实现任何东西的动画,如果有一个想做动画的复杂类型,没有什么能阻止添加自己的 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
        

  • 唯一要做的,就是写出形状来适当地定位针头,时钟形状的完整代码,可在本文最后的完整示例的 Example5 中找到。

四、SwiftUI + Metal

  • 如果正在编写复杂的动画,可能我们的设备会受到影响,试图跟上所有的绘图。如下所示,启用 Metal 后,一切都会变得不同:

  • 在模拟器上运行时,可能感觉不到有什么不同,然而,在真机设备上感受会更加直观。幸运的是,启用 Metal,是非常容易的,只需要添加 .drawingGroup() 修饰符:
FlowerView().drawingGroup()
  • 根据 WWDC 2019(用 SwiftUI 构建自定义视图):绘图组是一种特殊的渲染方式,但只适用于图形等东西,它基本上会将 SwiftUI 视图平铺到一个单一的 NSView/UIView 中,并用 Metal 进行渲染。如果你想尝试一下,但形状还没有复杂到让设备挣扎的地步,添加一些渐变和阴影,就会立即看到不同。

六、完整示例

swiftui之深入解析如何定制视图的动画和转场(代码片段)

一、前言使用SwiftUI可以把视图状态的改变转成动画过程,SwiftUI会处理所有复杂的动画细节。本文中,会给跟踪用户徒步的图表视图添加动画,使用animation(_:)修改器给一个视图添加动画效果非常容易。可以下载文末的... 查看详情

swift之深入解析如何结合coredata和swiftui

SwiftUI和CoreData之间相差将近十年,SwiftUI随着iOS13面世,而CoreData则是iPhoneOS3的产物;很久以前,它还没有被称为iOS,因为iPad尚未发布。尽管时间相距遥远,Apple还是投入了大量工作以确保这两种强大的技术能够完美地相互配合使... 查看详情

swift之深入解析如何使用swiftui实现3dscroll效果(代码片段)

一、SwiftUI视图创建首先,创建一个新的SwiftUI视图,为了举例说明,在这个新视图中,会展示一个有各种颜色的矩形列表,并把新视图命名为ColorList:importSwiftUIstructColorList:Viewvarbody:someViewText("Hello,World!&#... 查看详情

swiftui之深入解析如何使用组合矩形geometryreader创建条形(柱状)图(代码片段)

...数据的类别,其宽度和高度与它们表示的值成比例。SwiftUI对探索不同布局和预览实时视图结果是很友好的,很容易将部分内容提取到子视图中,以便每个部分都很小且易于维护。从包含HistogramView以及可能的其它文本... 查看详情

swiftui之深入解析如何使用组合矩形geometryreader创建条形(柱状)图(代码片段)

...数据的类别,其宽度和高度与它们表示的值成比例。SwiftUI对探索不同布局和预览实时视图结果是很友好的,很容易将部分内容提取到子视图中,以便每个部分都很小且易于维护。从包含HistogramView以及可能的其它文本... 查看详情

git之深入解析高级合并(代码片段)

...暂存区和轻量级地分支及合并的威力。如果想进一步对Git深入学习,可以学习一些Git更加强大的功能,这些功能可能并不会在日常操作中使用,但在某些时候可能还是会起到一定的关键性作用。如果还不清楚Git的基础... 查看详情

swiftui之深入解析如何处理特定的数据和如何在视图中适配数据模型对象(代码片段)

...言阅读了我的前两篇博客的朋友,应该都熟练掌握了SwiftUI如何创建一个任何相关信息的展示视图和各个视图之间的相互组合,以及动态生成一个展示相关信息的可滚动列表,用户可以点击列表项去查看其相关的详细... 查看详情

swiftui之深入解析如何创建和组合视图(代码片段)

一、创建项目并体验画布①系统要求创建SwiftUI项目工程,体验画布、预览模式和SwiftUI模板代码;要想在Xcode中预览画布中的视图,或者与画布中的视图进行交互,需要Mac系统版本号不低于macOSCatalina10.15。②步骤打... 查看详情

swift之深入解析swiftui布局如何自定义alignmentguides(代码片段)

SwiftUI提供了视图不同边缘的对齐指南(.leading、trailing、top等)以及.center和两个基线选项来帮助文本对齐。然而,当处理在不同视图之间分割的视图时,如果必须使在用户界面完全不同的两个视图部分对齐,... 查看详情

swift之深入解析swiftui属性包装器如何处理结构体(代码片段)

已经了解了SwiftUI如何通过使用@State属性包装器将变化的数据存储在结构体中,如何使用$将状态绑定到UI控件的值,以及更改@state包装的属性时是如何自动让SwiftUI重新调用结构体的body属性的。所有这些结合在一起&#x... 查看详情

swiftui之深入解析如何创建列表展示页和导航跳转详情页(代码片段)

...情。地标详情页视图的创建,请参考我的博客:SwiftUI之深入解析如何创建和组合视图。本文将分析如何创建一个可以展示任何地标信息的视图,并动态生成一个可滚动列表,用户可以点击列表项去查看地标的详细... 查看详情

swiftui之深入解析@stateobject@observedobject和@environmentobject的联系和区别(代码片段)

...f1f;状态在任何现代应用程序中都是不可避免的,但在SwiftUI中,重要的是所有的视图都是它们状态的简单函数,我们不需要直接改变视图,而是操纵状态,让状态决定结果。SwiftUI提供了几种在应用程序中存储... 查看详情

python之深入解析box为字典添加高级点符号访问特性(代码片段)

一、前言正常情况下,想访问字典中的某个值,都是通过中括号访问,比如:test_dict="test":"imdbstars":6.7,"length":104print(test_dict["test"]["imdbstars"])#104而通过B 查看详情

数据结构与算法之深入解析“出界的路径数”的求解思路与算法示例(代码片段)

一、题目要求给你一个大小为mxn的网格和一个球,球的起始坐标为[startRow,startColumn],你可以将球移到在四个方向上相邻的单元格内(可以穿过网格边界到达网格之外),但最多可以移动maxMove次球。给你五个整数m、n、maxMove、star... 查看详情

数据结构与算法之深入解析“不同路径”的求解思路与算法示例(代码片段)

一、题目要求一个机器人位于一个mxn网格的左上角(起始点在下图中标记为“Start”),机器人每次只能向下或者向右移动一步,机器人试图达到网格的右下角(在下图中标记为“Finish”),问总共有... 查看详情

SwiftUI 视图以意想不到的路径动画

】SwiftUI视图以意想不到的路径动画【英文标题】:SwiftUIviewanimatesatunexpectedpath【发布时间】:2021-10-0509:57:19【问题描述】:我做了以下PulsatingView:structPulsatingView:View@Stateprivatevaropacity=0.7@Stateprivatevarscale:CGFloat=0.5varbody:someViewVStackZ 查看详情

SwiftUI |动画此路径形状

】SwiftUI|动画此路径形状【英文标题】:SwiftUI|AnimateThisPathShape【发布时间】:2020-10-2013:32:40【问题描述】:我有以下情况:我想用自定义动画翻转这个形状。不知道怎么形容。每当我点击箭头时,它应该转换到另一个箭头。无需... 查看详情

数据结构与算法之深入解析“二叉树中的列表”的求解思路与算法示例(代码片段)

一、题目要求给你一棵以root为根的二叉树和一个head为第一个节点的链表,如果在二叉树中,存在一条一直向下的路径,且每个点的数值恰好一一对应以head为首的链表中每个节点的值,那么请你返回True,否则返回False。一直向下... 查看详情