[SwiftUI-Lab] SwiftUI动画进阶 - Part2 GeometryEffect(几何效果)

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

作者: Javier

翻译: Liaoworking

在本系列的第一部分,我介绍了Animatable协议,我们现在已经可以把它用到Path的动画了,下面我们将运用GeometryEffect(几何效果)把同样的协议用到矩阵转换的动画上。如果你还没有看Part1或者还不知道Animatable协议是什么, 你可以先看看。如果你只对GeometryEffect 感兴趣,那就算了。

可以在下面的网址找到本文的完整示例代码:
[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)

GeometryEffect(几何效果)

GeometryEffect是一个遵守了Animatable 和 ViewModifier 的协议,遵守GeometryEffect协议需要实现下面的方法。

func effectValue(size: CGSize) -> ProjectionTransform

假设你的方法叫做 SkewEffect(偏斜效果) ,使用起来如下。

Text("Hello").modifier(SkewEfect(skewValue: 0.5))

Text("Hello")将由SkewEfect.effectValue() 生成的动画来实现矩形转化动画。 就这么简单。只是影响当前视图,父级和子级视图都不会受到影响。

因为GeometryEffect也遵守了Animatable,所以你可以添加一个animatableData属性之类的,所以就会有动画效果。

你可能没有注意到,你其实一直在使用GeometryEffect(几何效果),如果你之前用过.offset(),你实际上就已经使用了GeometryEffect,让我来演示一下是这么实现的。

public extension View {
    func offset(x: CGFloat, y: CGFloat) -> some View {
        return modifier(_OffsetEffect(offset: CGSize(width: x, height: y)))
    }

    func offset(_ offset: CGSize) -> some View {
        return modifier(_OffsetEffect(offset: offset))
    }
}

struct _OffsetEffect: GeometryEffect {
    var offset: CGSize
    
    var animatableData: CGSize.AnimatableData {
        get { CGSize.AnimatableData(offset.width, offset.height) }
        set { offset = CGSize(width: newValue.first, height: newValue.second) }
    }

    public func effectValue(size: CGSize) -> ProjectionTransform {
        return ProjectionTransform(CGAffineTransform(translationX: offset.width, y: offset.height))
    }
}

关键帧动画

大多数动画的框架都有关键帧的概念。它是在闭包中告诉动画框架如何去区分动画,虽然SwiftUI没有这些特性,我们可以取模拟这些,在接下来的例子中,我们要去创建一个水平移动视图的效果,但它一开始会斜歪,结束的时候不会斜歪。

image

斜歪的效果前80%会增加,后20%会减少。中间的时候斜歪的效果会稳定不动。

一开始先创建斜歪和运动的效果,先不管最后20%的效果减少。如果你对矩阵转换还不太了解,没关系,只要知道CGAffineTransform的C参数控制斜歪,tx控制x方向的偏移。

image
struct SkewedOffset: GeometryEffect {
    var offset: CGFloat
    var skew: CGFloat
    
    var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get { AnimatablePair(offset, skew) }
        set {
            offset = newValue.first
            skew = newValue.second
        }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: skew, d: 1, tx: offset, ty: 0))
    }
}

做假

下面就是有趣的部分了,为了模拟关键帧,我们将定义一个值的范围是0-1的可动参数,我们的代码应该像这样来改变动画的,当它的值是0.2的时候,我们实现了动画的前20%,当参数值是大于等于0.8的时候,我们到达最后的20%。最重要的是,我们还会告诉动画框架我们是向左还是向右移动。所以它能两边都斜歪。

struct SkewedOffset: GeometryEffect {
    var offset: CGFloat
    var pct: CGFloat
    let goingRight: Bool

    init(offset: CGFloat, pct: CGFloat, goingRight: Bool) {
        self.offset = offset
        self.pct = pct
        self.goingRight = goingRight
    }

    var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get { return AnimatablePair<CGFloat, CGFloat>(offset, pct) }
        set {
            offset = newValue.first
            pct = newValue.second
        }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {
        var skew: CGFloat

        if pct < 0.2 {
            skew = (pct * 5) * 0.5 * (goingRight ? -1 : 1)
        } else if pct > 0.8 {
            skew = ((1 - pct) * 5) * 0.5 * (goingRight ? -1 : 1)
        } else {
            skew = 0.5 * (goingRight ? -1 : 1)
        }

        return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: skew, d: 1, tx: offset, ty: 0))
    }
}

下面想要玩的更有趣一点,我们将要在多个视图上去使用这个动画,使用动画修改器 .delay() 来让动画交错,完整代码在顶部的gist文件中的 Example6

image

动画反馈

下一个例子中将介绍一个对动画过程起反馈作用的工具。

我们将要创建一个3D选择效果,虽然SwiftUI已经有对应的修改器了,.rotation3DEffect()比较特别,每当我们的视图旋转到足以向我们展示另一面时,Bool值就会被更新。

通过对Bool值的改变,我们可以在旋转的时候替换视图。这会让我们有一种这个视图有两面的错觉。

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

实现我们的特效

让我们来实现这个特性,你可能会发现这个3D选择特效和你之前在Core Animation里的使用不太一样。在SwiftUI中,默认的锚点在视图的左上角,在Core Animation中是在中间,虽然现有的.rotationg3DEffect() 修改器可以让你选择锚点,但想要达到现有的效果,需要结合其他一些转换:

struct FlipEffect: GeometryEffect {
    
    var animatableData: Double {
        get { angle }
        set { angle = newValue }
    }
    
    @Binding var flipped: Bool
    var angle: Double
    let axis: (x: CGFloat, y: CGFloat)
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        
        // 我们计划在绘制完成后去改变,
        // 否则会收到一个runtime的error,
        // 来告诉我们在绘制的时候去改变视图了。
        DispatchQueue.main.async {
            self.flipped = self.angle >= 90 && self.angle < 270
        }
        
        let a = CGFloat(Angle(degrees: angle).radians)
        
        var transform3d = CATransform3DIdentity;
        transform3d.m34 = -1/max(size.width, size.height)
        
        transform3d = CATransform3DRotate(transform3d, a, axis.x, axis.y, 0)
        transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0, 0)
        
        let affineTransform = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height / 2.0))
        
        return ProjectionTransform(transform3d).concatenating(affineTransform)
    }
}

上面的代码中有一个有趣的点,flipped(翻动)属性是由@Binding修饰的,可以通知用户哪一面是朝着用户的。

在我们的视图中,使用flipped的值来显示不同的视图,在这个例子中打算使用一些取巧的方法。如果你仔细看视频就会发现卡片一直在变,背景一直是一样的,每次都是前面的在变,这并不是简单的一边一个视图,而是在每次flipped值改变的时候去替换一张卡片。

我们拥有一个图片名的数组,里面每个都会用到。先绑定自定义的几个变量,如下

struct RotatingCard: View {
    @State private var flipped = false
    @State private var animate3d = false
    @State private var rotate = false
    @State private var imgIndex = 0
    
    let images = ["diamonds-7", "clubs-8", "diamonds-6", "clubs-b", "hearts-2", "diamonds-b"]
    
    var body: some View {
        let binding = Binding<Bool>(get: { self.flipped }, set: { self.updateBinding($0) })
        
        return VStack {
            Spacer()
            Image(flipped ? "back" : images[imgIndex]).resizable()
                .frame(width: 265, height: 400)
                .modifier(FlipEffect(flipped: binding, angle: animate3d ? 360 : 0, axis: (x: 1, y: 5)))
                .rotationEffect(Angle(degrees: rotate ? 0 : 360))
                .onAppear {
                    withAnimation(Animation.linear(duration: 4.0).repeatForever(autoreverses: false)) {
                        self.animate3d = true
                    }
                    
                    withAnimation(Animation.linear(duration: 8.0).repeatForever(autoreverses: false)) {
                        self.rotate = true
                    }
            }
            Spacer()
        }
    }
    
    func updateBinding(_ value: Bool) {
        // 如果卡片翻到前面了 更换卡片
        if flipped != value && !flipped {
            self.imgIndex = self.imgIndex+1 < self.images.count ? self.imgIndex+1 : 0
        }
        
        flipped = value
    }
}

完整的代码在顶部的gist的Example 7 中。

我们打算更换不同的卡片,而是改变图片的名字,例子如下:

Color.clear.overlay(ViewSwapper(showFront: flipped))
    .frame(width: 265, height: 400)
    .modifier(FlipEffect(flipped: $flipped, angle: animate3d ? 360 : 0, axis: (x: 1, y: 5)))



struct ViewSwapper: View {
    let showFront: Bool
    
    var body: some View {
        Group {
            if showFront {
                FrontView()
            } else {
                BackView()
            }
        }
    }
}

让视图跟随路径

下面,我们来构造一个完全不一样的GeometryEffect(集合特效), 在这个例子中,将在一个特定的路线上移动小飞机。会存在两个问题。
1.如何在视图上获得这个坐标空间上特定的点的坐标。
2.小飞机的朝向也与路径相同

这个动画中的可变参数是pct,它代表着飞机在路线上的位置。用值0到1来表示飞机跑完一整圈,我们将要使,0.25的值代表飞机已经跑完四分之一圈了。

找到路线中的x和y值

为了通过给定的pct值来找到对应飞机的x和y值。我们将要使用.trimmedPath() 修改器来修改Path结构体。有一个方法是给定一个特定的百分比返回一个CGRect. 先定义两个特别接近的起点和终点,它将返回一个非常小的矩形,我们将用这个矩形的中心来当做我们的x和y值。

func percentPoint(_ percent: CGFloat) -> CGPoint {
    // 两点之间的百分比差距
    let diff: CGFloat = 0.001
    let comp: CGFloat = 1 - diff
    
    // 处理极值
    let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
    
    let f = pct > comp ? comp : pct
    let t = pct > comp ? 1 : pct + diff
    let tp = path.trimmedPath(from: f, to: t)
    
    return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
}

校正方向

为了获得飞机的转向,我们用一点三角学的知识,我们将获得两个点的x和y值,当前点和稍微偏靠前的点。我们把两个点连成一条线,然后通过三角函数的知识,就能求出转向角。

func calculateDirection(_ pt1: CGPoint,_ pt2: CGPoint) -> CGFloat {
    let a = pt2.x - pt1.x
    let b = pt2.y - pt1.y
    
    let angle = a < 0 ? atan(Double(b / a)) : atan(Double(b / a)) - Double.pi
    
    return CGFloat(angle)
}

把所有的都组合在一个

我们已经获得了所有可以达到目标的工具,我们来实现这个效果吧:

struct FollowEffect: GeometryEffect {
    var pct: CGFloat = 0
    let path: Path
    var rotate = true
    
    var animatableData: CGFloat {
        get { return pct }
        set { pct = newValue }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        if !rotate {
            let pt = percentPoint(pct)
            
            return ProjectionTransform(CGAffineTransform(translationX: pt.x, y: pt.y))
        } else {
            let pt1 = percentPoint(pct)
            let pt2 = percentPoint(pct - 0.01)
            
            let angle = calculateDirection(pt1, pt2)
            let transform = CGAffineTransform(translationX: pt1.x, y: pt1.y).rotated(by: angle)
            
            return ProjectionTransform(transform)
        }
    }
    
    func percentPoint(_ percent: CGFloat) -> CGPoint {
        // 两点之间的百分比查
        let diff: CGFloat = 0.001
        let comp: CGFloat = 1 - diff
        
        // 处理极值
        let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
        
        let f = pct > comp ? comp : pct
        let t = pct > comp ? 1 : pct + diff
        let tp = path.trimmedPath(from: f, to: t)
        
        return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
    }
    
    func calculateDirection(_ pt1: CGPoint,_ pt2: CGPoint) -> CGFloat {
        let a = pt2.x - pt1.x
        let b = pt2.y - pt1.y
        
        let angle = a < 0 ? atan(Double(b / a)) : atan(Double(b / a)) - Double.pi
        
        return CGFloat(angle)
    }
}

所有的代码在文章顶部的gist的Example 8 中。

被布局忽略的

关于GeometryEffect的最后一个建议就是.ignoredByLayout()方法,先看看文档怎么说:

Returns an effect producing the same geometry transform as “self” but that will only be applied while rendering its view, not while the view is performing its layout calculations. This is often used to disable layout changes during transitions, but that will only be applied while rendering its view, not while the view is performing its layout calculations. This is often used to disable layout changes during transitions.
在渲染视图的时候返回一个与“Self”相同的几何交换效果,计算布局的时候不返回,通常被用来在做动画的时候禁止布局改变。通常用于在过渡期间禁用布局更改。

马上就介绍一下转换,先举一个例子来说明一下使用了.ignoredByLayout() 所带来的明显效果。下图中的GeometryReader 会显示两个不同的位置。

struct ContentView: View {
    @State private var animate = false
    
    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius: 5)
                .foregroundColor(.green)
                .frame(width: 300, height: 50)
                .overlay(ShowSize())
                .modifier(MyEffect(x: animate ? -10 : 10))
            
            RoundedRectangle(cornerRadius: 5)
                .foregroundColor(.blue)
                .frame(width: 300, height: 50)
                .overlay(ShowSize())
                .modifier(MyEffect(x: animate ? 10 : -10).ignoredByLayout())
            
        }.onAppear {
            withAnimation(Animation.easeInOut(duration: 1.0).repeatForever()) {
                self.animate = true
            }
        }
    }
}

struct MyEffect: GeometryEffect {
    var x: CGFloat = 0
    
    var animatableData: CGFloat {
        get { x }
        set { x = newValue }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        return ProjectionTransform(CGAffineTransform(translationX: x, y: 0))
    }
}

struct ShowSize: View {
    var body: some View {
        GeometryReader { proxy in
            Text("x = \(Int(proxy.frame(in: .global).minX))")
                .foregroundColor(.white)
        }
    }
}

下面会学到什么

今天举的三个例子,都有些类似,都使用相同的协议来实现效果,GeometryEffect比较简单,只用实现一个方法,但可以发挥很大的作用。
下面一节,我们将介绍最后一个协议AnimatableModifier, AnimatableModifier可以做出很多炫酷的动画。

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

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

推荐阅读更多精彩内容

  • 小美和小丽是闺蜜,两人聊起了婚姻 小美:“男人,真可恨!” 小丽:“怎么可恨?” 小美:“他睡了一个女人,却不娶她...
    爱叮叮阅读 444评论 0 1
  • 感恩天地滋养万物,感恩宇宙永恒,感恩大自然无私的奉献,感恩祖宗护佑,感恩风调雨顺,国泰民安。 感恩父母生养之恩,感...
    黄巧珍阅读 160评论 0 2
  • 面对人生,你是要抬头仰望月亮,还是低头捡起六便士? 大家身边可能都有这样的人,做着稳定的工作,有着幸福的家庭,三两...
    晴晴fineyoga阅读 272评论 2 4
  • 从冬季再到冬季,整整就这样默默的喜欢了四个季度。直到今天才彻底明白,你的犹豫不决就是不喜欢我。你所说的喜欢我只是想...
    因吹死挺阅读 218评论 0 0