阅读 235

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

文章源地址:swiftui-lab.com/swiftui-ani…

作者: 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值的改变,我们可以在旋转的时候替换视图。这会让我们有一种这个视图有两面的错觉。

实现我们的特效

让我们来实现这个特性,你可能会发现这个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可以做出很多炫酷的动画。

关注下面的标签,发现更多相似文章
评论