SwiftUI之深入解析高级动画的几何效果GeometryEffect

SwiftUI之深入解析高级动画的几何效果GeometryEffect,第1张

一、前言 在我的博客 SwiftUI之深入解析高级动画的路径Paths 中,已经了解了 Animatable 的协议,以及如何使用它来动画路径。接下来,我们将使用相同的协议来动画变换矩阵,使用一个新的工具:几何效果。几何效果是一个协议,符合 Animatable 和ViewModifier。为了符合几何效果,需要实现以下方法:
func effectValue(size: CGSize) -> ProjectionTransform
假设方法叫做 SkewEffect,为了将它应用到一个视图,可以这样使用它:
Text("Hello").modifier(SkewEfect(skewValue: 0.5))
Text(“Hello”) 将使用 SkewEfect.effectValue() 方法创建的矩阵进行转换,就这么简单。注意,这些更改将影响视图,但不会影响其祖先或后代的布局,因为 GeometryEffect 也符合 Animatable,可以添加一个 animatableData 属性,有一个 Animatable 效果。你可能没有意识到,但你可能一直在使用几何效果。如果你曾经使用过 .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 没有这些特性,但我们可以模拟它。如下所示,创建一个效果,使视图水平移动,但它在开始时倾斜,在结束时不倾斜:

倾斜效果需要在动画的前 20% 和后 20% 期间增加和减少,在中间倾斜效应将保持稳定,那么如何解决它呢?我们创建一个倾斜和移动我们的观点的效果,不需要注意 20% 的要求,CGAffineTransform c 参数驱动倾,tx,x offset::

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() 动画修饰符。完整的代码可以参考文末的完整示例,在实例 6 中可以找到:

三、动画反馈 现在来创建一个效果,执行 3d 旋转,尽管 SwiftUI 已经有了一个修饰符,即 rotation3deeffect(),但这个修饰符比较特别,每当视图旋转到足够显示另一边时,一个布尔绑定将被更新;通过对绑定变量的变化做出反应,我们将能够替换正在旋转动画的过程中的视图,这将创造一种错觉,即视图有两个面,如下所示:

你可能会注意到,三维旋转变换可能与在核心动画中的习惯略有不同。在 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 的值来有条件地显示两个视图中的一个。然而,在示例中,我们将更多使用一个技巧,如果你仔细观察上面的 gif 图,你会发现这张牌一直在变化,虽然背面总是一样的,但正面却每次都在变化。因此,这不是简单的为一面展示一个视图,为另一面展示另一个视图,我们不是基于 flipped 的值,而是要监测 flipped 的值的变化,然后每一个完整的回合,都将使用不同的牌。有一个图像名称的数组,想去逐一查看,为了做到这一点,我们将会使用一个自定义绑定变量(完整的代码可以在文末完整示例中的实例 7 中找到):
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
    }
}
如前所述,我们可能想使用两个完全不同的视图,而不是改变图像名称,这也是可以的:
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,我们的效果将通过一个任意的路径移动一个视图,需要注意两个问题: 如何获取路径中特定点的坐标; 如何在通过路径移动时确定视图的方向。

这个效果的可动画参数将是 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)
    }
}
五、ignoredByLayout() 方法 对 GeometryEffect 几何效果的最后一个技巧是 .ignoredByLayout() 方法,看看文档的描述:
Returns an effect producing the same geometry transform asself” 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.
大致意思为:返回一个产生与此效果相同的几何变换的效果,只需要在渲染其视图时应用该变换。使用此方法可以在转换期间禁用布局更改,在视图执行布局计算时,视图将忽略此方法返回的变换。如下所示一个例子,使用 .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)
        }
    }
}
六、完整示例 SwiftUI高级动画之路径Paths、几何效果GeometryEffect与AnimatableModifier的效果实现。

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/web/994124.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-21
下一篇 2022-05-21

发表评论

登录后才能评论

评论列表(0条)

保存