美文网首页SwiftUI
[SwiftUI-Lab]SwiftUI动画进阶 - Part1

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

作者: liaoworkinn | 来源:发表于2019-12-27 09:55 被阅读0次

    文章源地址:[https://swiftui-lab.com/swiftui-animations-part1/)

    作者: 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 { ... }闭包修饰的,只有闭包中的可动参数改变了才会执行动画,来几个例子演示一下。

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

    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,它们的形状相对,但是多了一条线,把每个顶点相互连接起来。

    <video width='500px' autoplay preload='true' controls src="https://swiftui-lab.com/wp-content/uploads/2019/08/example4.mp4">
    </video>

    两个以上的动画参数

    如果你有翻阅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来移动它的指针。

    <video width='500px' autoplay preload='true' controls src="https://swiftui-lab.com/wp-content/uploads/2019/08/clock.mp4">
    </video>

    最终会以这样使用:

    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到底有多流畅。

    <video width='500px' autoplay preload='true' controls src="https://swiftui-lab.com/wp-content/uploads/2019/08/metal-comparison.mov">
    </video>

    模拟器上可能感觉不到差压,但在真机上可能会感觉到,视频是从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!,三篇文章的动画内容都在下面的视频中了。

    <video width='500px' autoplay preload='true' controls src="https://swiftui-lab.com/wp-content/uploads/2019/08/animations.mp4">
    </video>
    高级SwiftUI动画

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

    相关文章

      网友评论

        本文标题:[SwiftUI-Lab]SwiftUI动画进阶 - Part1

        本文链接:https://www.haomeiwen.com/subject/qubroctx.html