这是我参加2022首次更文应战的第4天,活动概况检查:2022首次更文应战

高级 SwiftUI 动画 — Part 2:GeometryEffect

在本系列的榜首部分,我介绍了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 没有这些功能,但咱们能够模仿它。在下面的比如中,咱们将创立一个水平移动视图的作用,但它也会在开端时歪斜,在结束时撤销歪斜:

高级 SwiftUI 动画 — Part 2:GeometryEffect

歪斜作用需要在动画的榜首个和最终一个20%期间增加和减少。在中心,歪斜作用将保持稳定。好了,现在咱们有一个应战,让咱们看看怎么处理这个问题。

咱们将首要创立一个使咱们的视图歪斜和移动的作用,而不用太注意20%的要求。假如你对改换矩阵了解不多,那也不要紧。只需要知道:CGAffineTransform c 参数驱动歪斜,而 tx 则驱动 x 偏移。

高级 SwiftUI 动画 — Part 2:GeometryEffect

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 动画 — Part 2:GeometryEffect

动画反馈

在下一个比如中,我将向你展现一个简略的技能,它将使咱们的视图对作用动画的进展做出反应。

咱们将创立一个作用,让咱们进行三维旋转。尽管SwiftUI已经有了一个修饰符,即.rotrotation3DEffect(),但这个修饰符将是特别的。每当咱们的视图旋转到足以向咱们展现另一面时,一个布尔绑定将被更新。

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

高级 SwiftUI 动画 — Part 2:GeometryEffect

履行咱们的作用

让咱们开端创立咱们的作用。你会注意到,三维旋转改换或许与你在核心动画中的习气略有不同。在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.怎么在经过途径移动时确定视图的方向。在这个特定的案例中,咱们怎么知道飞机的机头指向哪里(扰流板警告,一点三角函数就能够了)。

高级 SwiftUI 动画 — Part 2: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)
    }
}

完好的代码可在本页面顶部链接的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())。

高级 SwiftUI 动画 — Part 2:GeometryEffect

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 Lab 的 Advanced SwiftUI Animations – Part 2: GeometryEffect

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

gist.github.com/swiftui-lab…

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

swiftui-lab.com/?smd_proces…