SwiftUI动画(2)之GeometryEffect

869 阅读11分钟

GeometryEffect

GeometryEffect实现了AnimatableViewModifier这两个协议,因此说明它自身就能实现动画,同时也可以通过modifier来写代码

大家可能比较疑惑,GeometryEffect在哪里用到了呢?其实用到的地方很多,比如系统中的offset就可以用其实现,代码如下:

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))
    }
}

Animation Keyframes

SwiftUI并没有直接提供关键帧动画相关的modifier,但我们完全可以用GeometryEffect来实现,但是在这以前,我们先介绍一个关于view形变的相关的一个核心内容CGAffineTransform

学过线性代数的同学一定明白,所谓的形变本质上就是把一些点的集合通过一个形变矩阵映射成另外一个点集。这里边设计到了矩阵的乘法。

形变的主要内容有:

  • 平移
  • 缩放
  • 旋转

举个例子,看下图:

由于我们的手机是一个二维平面,A(10, 10)平移到A‘(20, 20)只需要分别在x,y轴都加上10就可以了,用数学表达式:

\begin{bmatrix}10 & 10 \end{bmatrix} + \begin{bmatrix}10 & 10 \end{bmatrix} \longrightarrow  \begin{bmatrix}20 & 20 \end{bmatrix}\tag{1}

也就是说平移算法其实就是矩阵的加法,如果只考虑平移,我们用一个二维向量就可以表示,我们继续往下看缩放的例子:

我们先规定一开始的矩形没有缩放操作,我们把矩形放大3倍,本质上就相当于对单位矩阵进行缩放,然后在乘以缩放后的单位矩阵

\begin{bmatrix}1 & 0 \\\\ 0 & 1 \end{bmatrix} \times 3  \longrightarrow \begin{bmatrix}3 & 0 \\\\ 0 & 3 \end{bmatrix} \tag{2}
\begin{bmatrix}5 & 5 \end{bmatrix} \times \begin{bmatrix}3 & 0 \\\\ 0 & 3 \end{bmatrix}   \longrightarrow \begin{bmatrix}15 & 15 \end{bmatrix} \tag{3}

同理,旋转也可以理解为先对单位矩阵进行旋转:

x = r\cos(\beta)
\\
y = r\sin(\beta)
\tag{4}

其中我们tuidao用到的两角和差公式为:

\cos(\beta + \alpha) = \cos(\beta)\cos(\alpha) - \sin(\beta)\sin(\alpha) \\
\sin(\beta + \alpha) = \sin(\beta)\cos(\alpha) + \cos(\beta)\sin(\alpha) \tag{5}

我们根据公式4和公式5就可以算出x’,y'的值:

\begin{align*}
x' & = r\cos(\beta + \alpha) \\
& = r(\cos(\beta)\cos(\alpha) - \sin(\beta)\sin(\alpha)) \\
& = r\cos(\beta)\cos(\alpha) - r\sin(\beta)\sin(\alpha) \\
& = x\cos(\alpha) - y\sin(\alpha)
\end{align*} \tag{6}
\begin{align*}
y' & = r\sin(\beta + \alpha) \\
& = r(\sin(\beta)\cos(\alpha) + \cos(\beta)\sin(\alpha)) \\
& = r\sin(\beta)\cos(\alpha) + r\cos(\beta)\sin(\alpha) \\
& = y\cos(\alpha) + x\sin(\alpha)
\end{align*} \tag{7}

现在已经非常明显了,用一点线性代数的知识,我们就可以推导出旋转矩阵:

\begin{bmatrix}
x & y 
\end{bmatrix}
\begin{bmatrix}
\cos(\alpha) & \cos(\alpha) \\
-\sin(\alpha) & \sin(\alpha)
\end{bmatrix}
\longrightarrow
\begin{bmatrix}
x' & y' 
\end{bmatrix} \tag{8}

因此,旋转矩阵为:

\begin{bmatrix}
\cos(\alpha) & \cos(\alpha) \\
-\sin(\alpha) & \sin(\alpha)
\end{bmatrix} \tag{9}

注意,到目前位置,我们讨论的只是二维平面上的形变,至于三维的,也可以根据这套方法推导出,在这里就不做更多的解释了。

相信大家一定有个疑问?位移用矩阵加法,缩放和旋转用矩阵乘法,能否这3个形变都使用同一个运算呢?答案是有的,如果没有线性代数的知识,理解起来就会比较困难。

我们把2维的点升维到3维,大家可以想象x,y轴处于屏幕上,新增一个z轴,由屏幕指向我们头部,原来我们的点都在屏幕上,也就是z都为0,我们现在使用z=1的平面,这个平面与屏幕平行,于是我们得到:

\begin{bmatrix} x & y & 1 \end{bmatrix} \tag{10}
\begin{align*}
\begin{bmatrix} x & y & 1 \end{bmatrix}
\begin{bmatrix} a & b & 0 \\\\ c & d & 0 \\\\ e & f & 1 \end{bmatrix} = 
\begin{bmatrix} xa + yc + e & xb+yd+f & 1 \end{bmatrix}
\end{align*} \tag{11}

我们仔细观察上边的公式,可以发现这样的对应关系:

\begin{align*}
\begin{bmatrix} x & y & 1 \end{bmatrix}
\longrightarrow
\begin{bmatrix} xa + yc + e & xb+yd+f & 1 \end{bmatrix}
\end{align*}

总结一下:

  • 只考率位移:x只需要设置e,abcdf都为0,tx;y只需要设置f,abcde都为0
  • 只考率缩放:x只需要设置a,bcdef都为0,sx;y只需要设置d,abcef都为0
  • 只考率旋转:x只需要设置a,c,bdef都为0,y只需要设置b,d,abcef都为0

好的,到目前我为止,我们已经统一了形变的操作。都可以用矩阵乘法来实现:

\begin{align*}\begin{bmatrix} a & b & 0 \\\\ c & d & 0 \\\\ tx & ty & 1 \end{bmatrix} \qquad \qquad\qquad \end{align*}

回过头来,我们再看下边这段代码:

    func effectValue(size: CGSize) -> ProjectionTransform {
        return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: skew, d: 1, tx: offset, ty: 0))
    }

再回看公式11,就不难理解,在x位移offset的情况下,x还要倾斜yc的距离,也就是x会随着不同的y有一定的倾斜角度,如下图:

Animation Feedback

动画反馈的意思指的是动画在进行中,我们监听动画当前执行的参数,然后根据这些参数去做一些其他的事情。

这句话不太好理解,举个例子:

上图中卡片有两种旋转:

  • 360旋转
  • 卡片沿着某个轴360度旋转

当卡片沿着某个轴360旋转的时候, 我们可以再effectValue中监听到动画当前旋转的角度,根据当前的这个角度我们主动的控制图片的内容,在本例中,当角度处于90~270之间时,显示背面的图片。一旦我们监听到旋转的角度达到90或者270的时候,我们替换显示的图片。

==这里的重点是,我们能够在effectValue中监听到当前动画的状态,然后基于此状态做额外的逻辑。==

那么我们是如何做到的呢?核心代码如下:

struct FlipEffect: GeometryEffect {
    @Binding var flipped: Bool
    var angle: Double
    let axis: (CGFloat, CGFloat)
    
    var animatableData: Double {
        get {
            angle
        }
        set {
            angle = newValue
        }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        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, self.axis.0, self.axis.1, 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)
    }
}

由于GeometryEffect实现了Animatable协议,系统会根据animatableData来动态的计算参数,这里需要计算的参数指的是angleangle会被打散成很多不同的数字,系统会针对每一个不同的angle都调用effectValue方法。

DispatchQueue.main.async {
            self.flipped = (self.angle >= 90 && self.angle < 270)
        }

我们根据当前的角度来决定flipped的值,因此flipped是频繁被赋值的。我们得出的结论是:系统根据动画函数计算angle,然后在effectValue中获取angle,再根据这个值处理我们自己的逻辑。

angle是一个Binding的值:@Binding var flipped: Bool,它的值的改变会往上层抛出,我们再看看下边的代码:

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 = ["1", "2", "3", "4", "5"]
    
    var body: some View {
        let binding = Binding<Bool>(get: { self.flipped }, set: { self.updateBinding($0) })
        
        return VStack {
            Spacer()
            Image(flipped ? "bg" : images[imgIndex]).resizable()
                .frame(width: 212, height: 320)
                .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 card was just flipped and at front, change the card
        if flipped != value && !flipped {
            self.imgIndex = self.imgIndex+1 < self.images.count ? self.imgIndex+1 : 0
        }
        
        flipped = value
    }
}

可以看出,依赖flipped在临界点的变化,我们切换iamgeIndex,从而实现了切换显示图片的目的。

这一小结的目的是让我们知道,可以在effectValue中获得动画状态。

Make a View Follow a Path

这一小节的挑战是让某一个view沿着某个path运动,对于比较简单的图形,我们可以使用AnimatableData来实现,比方说让小球沿着圆环运动,通过计算旋转的角度就可以实现。一旦路径变得复杂,我们就遇到了挑战,比如下边这样的效果:

很明显,为了能够让飞机沿着指定的路径运动,并且方向保持一致,需要做到以下两点:

  • 实时计算飞机的位置
  • 计算飞机的旋转方向

绘制path,用到了贝塞尔曲线的知识,这里就不做过多的解释了,只要你想,利用贝塞尔曲线能够画出任何路径。代码如下:

struct InfinityShape: Shape {
    func path(in rect: CGRect) -> Path {
        InfinityShape.createInfinityShape(in: rect)
    }
    
    static func createInfinityShape(in rect: CGRect) -> Path {
        let w = rect.width
        let h = rect.height
        let quarternW = w / 4.0
        let quarternH = h / 4.0
        
        var path = Path()
        
        path.move(to: CGPoint(x: quarternW, y: quarternH * 3))
        path.addCurve(to: CGPoint(x: quarternW, y: quarternH), control1: CGPoint(x: 0, y: quarternH * 3), control2: CGPoint(x: 0, y: quarternH))
        
        path.move(to: CGPoint(x: quarternW, y: quarternH))
        path.addCurve(to: CGPoint(x: quarternW * 3, y: quarternH * 3), control1: CGPoint(x: quarternW * 2, y: quarternH), control2: CGPoint(x: quarternW * 2, y: quarternH * 3))
        
        path.move(to: CGPoint(x: quarternW * 3, y: quarternH * 3))
        path.addCurve(to: CGPoint(x: quarternW * 3, y: quarternH), control1: CGPoint(x: w, y: quarternH * 3), control2: CGPoint(x: w, y: quarternH))
        
        path.move(to: CGPoint(x: quarternW * 3, y: quarternH))
        path.addCurve(to: CGPoint(x: quarternW, y: quarternH * 3), control1: CGPoint(x: quarternW * 2, y: quarternH), control2: CGPoint(x: quarternW * 2, y: quarternH * 3))
        
        return path
    }
}

大家看上边代码的时候,可以参考我下边画的这张图,

我们需要计算的参数是percent,取值范围为0~1,那么我们如何计算path中的具体的某一点的Point呢?path提供了一个方法:trimmedPath(from:, to: ),该方法会返回path中的某一段path,只要我们from到to的值取得足够小,我们就可以获取到极小的一段path,我们使用该段path的中心作为Point,代码如下:

    /// 计算当前的点的位置
    func percentPoint(_ percent: CGFloat) -> CGPoint {
        let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
        let f = pct > 0.999 ? 0.999 : pct
        let t = pct > 0.999 ? 1 : pct + 0.001
        let tp = path.trimmedPath(from: f, to: t)
        return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
    }

第二个挑战是如何获取某一点的旋转角度,有了上边的结果,我们就能够根据percent获取到某个Point,假设我们想获取某一点P的方向,我们可以根据P对应的(percent - 0.001)来获取P的前一个点,有了两个点,我们就可以计算方向了。先看代码:

    /// 计算两点角度
    func calculateDirection(_ pt1: CGPoint, _ pt2: CGPoint) -> CGFloat {
        let a = pt2.x - pt1.x
        let b = pt2.y - pt1.y
        
        let angle = (a > 0) ? atan(b / a) : atan(b / a) - CGFloat.pi
        
        return angle
    }

这里讲解以下let angle = (a > 0) ? atan(b / a) : atan(b / a) - CGFloat.pi这一行代码的意义,先看下图:

我们假设P0为前一个点,P1,P2, P3, P4分别为需要计算的点,在一个平面中,方向最多存在这4个方向,分别位于不同的象限中。

==注意,在iOS中,y是越往下越大的。角度是按照顺时针算的,上图中的角1是负的,角2才是正的。==

关于atan(), 举个例子假设atan(x) = 1.5,那么atan(-x) = -1.5。

        let a = pt2.x - pt1.x
        let b = pt2.y - pt1.y
  • P0 -> P1: a > 0 b < 0 atan(b/a)计算的结果是负的,正好是角1
  • P0 -> P2: a > 0 b > 0 atan(b/a)计算的结果是正的,正好是角2
  • P0 -> P3: a < 0 b > 0 atan(b/a)计算的结果是负的,结果是角1,这时候为了获取P0 -> P3的角度,需要再减去180度,也就是pi
  • P0 -> P4: a < 0 b < 0 atan(b/a)计算的结果是正的,结果是角2,这时候为了获取P0 -> P4的角度,需要再减去180度,也就是pi

不难发现,如果a>0,那么计算的结果正好是我们想要的角度,其他情况则需要在结果的基础上再减去180度,现在大家应该明白上边的代码了吧?

完整代码如下:

struct FollowEffect: GeometryEffect {
    var pct: CGFloat
    let path: Path
    
    var animatableData: CGFloat {
        get {
            pct
        }
        set {
            pct = newValue
        }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        let pt1 = percentPoint(pct - 0.01)
        let pt2 = percentPoint(pct)
        
        let angle = calculateDirection(pt1, pt2)
        let transform = CGAffineTransform(translationX: pt1.x, y: pt1.y).rotated(by: angle)
        
        return ProjectionTransform(transform)
    }
    
    /// 计算两点角度
    func calculateDirection(_ pt1: CGPoint, _ pt2: CGPoint) -> CGFloat {
        let a = pt2.x - pt1.x
        let b = pt2.y - pt1.y
        
        let angle = (a > 0) ? atan(b / a) : atan(b / a) - CGFloat.pi
        
        return angle
    }
    
    /// 计算当前的点的位置
    func percentPoint(_ percent: CGFloat) -> CGPoint {
        let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
        let f = pct > 0.999 ? 0.999 : pct
        let t = pct > 0.999 ? 1 : pct + 0.001
        let tp = path.trimmedPath(from: f, to: t)
        return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
    }
}
struct Example9: View {
    @State private var flag = false
    
    var body: some View {
        GeometryReader { proxy in
            ZStack(alignment: .topLeading) {
                InfinityShape().stroke(Color.green, style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .miter, miterLimit: 0, dash: [7, 7], dashPhase: 0))
                    .frame(width: proxy.size.width, height: 300)
                
                // Animate movement of Image
                Image(systemName: "airplane").resizable().foregroundColor(Color.red)
                    .frame(width: 50, height: 50).offset(x: -25, y: -25)
                    .modifier(FollowEffect(pct: self.flag ? 1 : 0, path: InfinityShape.createInfinityShape(in: CGRect(x: 0, y: 0, width: proxy.size.width, height: 300))))
                    .onAppear {
                        withAnimation(Animation.linear(duration: 4.0).repeatForever(autoreverses: false)) {
                            self.flag.toggle()
                        }
                    }

                }.frame(alignment: .topLeading)
            }
            .padding(20)
        }
}

Ignored By Layout

什么叫Ignored By Layout呢?其实这个概念在特定的场景下很有用。我们先看个演示:

可以看出,绿色长方块随着动画的变化,它的布局信息也在不断变化,而下边橙色的长方块则布局信息不会变化,当然,虽然它的布局信息在实时的变化,后边的蓝色长方块也不会自动跟随这些变化。

核心代码如下:

struct IgnoredByLayoutView: View {
    @State private var animate = false
    @State private var w: CGFloat = 50
    
    var body: some View {
        VStack {
            HStack {
                RoundedRectangle(cornerRadius: 5)
                    .foregroundColor(.green)
                    .frame(width: 200, height: 40)
                    .overlay(ShowSize())
                    .modifier(MyEffect(x: animate ? -10 : 10))
                
                RoundedRectangle(cornerRadius: 5)
                    .foregroundColor(.blue)
                    .frame(width: w, height: 40)
            }
            
            HStack {
                RoundedRectangle(cornerRadius: 5)
                    .foregroundColor(.orange)
                    .frame(width: 200, height: 40)
                    .overlay(ShowSize())
                    .modifier(MyEffect(x: animate ? -10 : 10).ignoredByLayout())
                
                RoundedRectangle(cornerRadius: 5)
                    .foregroundColor(.red)
                    .frame(width: w, height: 40)
            }
        }
        .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 = \(proxy.frame(in: .global).minX, specifier: "%.0f")")
                .foregroundColor(.white)
        }
    }
}

使用.ignoredByLayout(),可以让我们有能力在某些特殊的场景下,依然能够执行动画,但view的layout并不会实时计算。

总结

GeometryEffect本身已经遵守了Animatable协议,因此我们需要在自定义的effect中实现animatableData,这里边的值就是系统根据动画设置,自动计算的值,我们使用该值,在func effectValue(size: CGSize) -> ProjectionTransform函数中做一些必要的计算 最后返回一个ProjectionTransform,来告诉系统view的形变信息。

注:上边的内容参考了网站https://swiftui-lab.com/swiftui-animations-part2/,如有侵权,立即删除。