高级 SwiftUI 动画 — Part 2:GeometryEffect

3,350 阅读8分钟

这是我参与2022首次更文挑战的第4天,活动详情查看:2022首次更文挑战

在本系列的第一部分,我介绍了Animatable协议,以及我们如何使用它来为路径制作动画。接下来,我们将使用一个新的工具: GeometryEffect,用同样的协议对变换矩阵进行动画处理。如果你没有读过第一部分,也不知道Animatable协议是什么,你应该先读一下。或者如果你只是对GeometryEffect感兴趣,不关心动画,你可以跳过第一部分,继续阅读本文。

GeometryEffect

GeometryEffect是一个符合AnimatableViewModifier的协议。为了符合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))
    }
}

Animation Keyframes

大多数动画框架都有关键帧的概念。它是一种告诉动画引擎将动画分成若干块的方式。虽然 SwiftUI 没有这些功能,但我们可以模拟它。在下面的例子中,我们将创建一个水平移动视图的效果,但它也会在开始时倾斜,在结束时取消倾斜:

倾斜效果需要在动画的第一个和最后一个20%期间增加和减少。在中间,倾斜效果将保持稳定。好了,现在我们有一个挑战,让我们看看如何解决这个问题。

我们将首先创建一个使我们的视图倾斜和移动的效果,而不必太注意20%的要求。如果你对变换矩阵了解不多,那也没关系。只需要知道:CGAffineTransform c 参数驱动倾斜,而 tx 则驱动 x 偏移。

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文件中 实例6 获得。

动画反馈

在下一个例子中,我将向你展示一个简单的技术,它将使我们的视图对效果动画的进展做出反应。

我们将创建一个效果,让我们进行三维旋转。虽然SwiftUI已经有了一个修饰符,即.rotrotation3DEffect(),但这个修饰符将是特别的。每当我们的视图旋转到足以向我们展示另一面时,一个布尔绑定将被更新。

通过对绑定变量的变化做出反应,我们将能够替换正在旋转动画的过程中的视图。这将创造一种错觉,即视图有两个面。下面是一个例子:

落实我们的效果

让我们开始创建我们的效果。你会注意到,三维旋转变换可能与你在核心动画中的习惯略有不同。在SwiftUI中,默认的锚点是在视图的前角,而在Core Animation中是在中心。虽然现有的.rotrotingg3DEffect()修饰符可以让你指定一个锚点,但我们正在建立我们自己的效果。这意味着我们必须自己处理它。由于我们不能改变锚点,我们需要在组合中加入一些转换效果:

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 {
        
        // 我们把修改安排在视图绘制完成后进行。
        // 否则,我们会收到一个运行时错误,表明我们正在改变
        // 视图正在绘制时改变状态。
        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)
    }
}

通过查看几何效果代码,有一个有趣的事实。我们用@Bindingd属性flipped来向视图报告,哪一面是面向用户的。

在我们的视图中,我们将使用flipped的值来有条件地显示两个视图中的一个。然而,在这个具体的例子中,我们将使用一个更多的技巧。如果你仔细观察视频,你会发现这张牌一直在变化。背面总是一样的,但正面却每次都在变化。因此,这不是简单的为一面展示一个视图,为另一面展示另一个视图。我们不是基于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 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
    }
}

完整的代码可在本页顶部链接的gist文件中的 实例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的值,它意味着飞机已经前进了1/4的路径。

寻找路径中的x、y位置

为了获得飞机在给定的pct值下的x和y位置,我们将使用Path结构体的 .trimmedPath() 修饰符。给定一个起点和终点百分比,该方法返回一个CGRect。它包含了该段路径的边界。根据我们的需求,我们只需用使用非常接近的起点和终点来调用它。它将返回一个非常小的矩形,我们将使用其中心作为我们的X和Y位置。

func percentPoint(_ percent: CGFloat) -> CGPoint {
    // percent difference between points
    let diff: CGFloat = 0.001
    let comp: CGFloat = 1 - diff
    
    // handle limits
    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 { // Skip rotation login
            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 {
        // percent difference between points
        let diff: CGFloat = 0.001
        let comp: CGFloat = 1 - diff
        
        // handle limits
        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文件中以 Example8 的形式提供。

Ignored By Layout

我们对GeometryEffect的最后技巧是方 .ignoredByLayout() 。让我们看看文档中是怎么说的:

Returns an effect that produces the same geometry transform as this effect, but only applies the transform while rendering its view.

返回一个产生与此效果相同的几何变换的效果,但只在渲染其视图时应用该变换。

Use this method to disable layout changes during transitions. The view ignores the transform returned by this method while the view is performing its layout calculations.

使用此方法可以在转换期间禁用布局更改。在视图执行布局计算时,视图将忽略此方法返回的变换。

我很快就会介绍过渡的内容。同时,让我介绍一个例子,使用.ignoredByLayout()有一些明显的效果。我们将看到GeometryReader是如何报告不同的位置的,这取决于效果是如何被添加的(即,有或没有.ignoredByLayout())。

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。如果GeometryEffect很强大,那就等着看你能用AnimatableModifier做的所有精彩事情吧。下面是整个系列的一个快速预览:

swiftui-lab.com/wp-content/…

译自 The SwiftUI LabAdvanced SwiftUI Animations – Part 2: GeometryEffect

本文的完整示例代码可在以下位置找到:

gist.github.com/swiftui-lab…

示例8 需要的图片资源。从这里下载:

swiftui-lab.com/?smd_proces…