iOS 复杂的动画协调如 Android Animator(Set)

iOS complex animation coordination like Android Animator(Set)

我使用 Animator 类 在我的 Android 应用程序中制作了一个相当复杂的动画。我想将此动画移植到 iOS。最好它有点像 Android Animator。我环顾四周,似乎没有什么是我想要的。我得到的最接近的是 CAAnimation。但不幸的是,如果将他们放在一个组中,所有子代表都会被忽略。

让我从我在 Android 上制作的动画开始。我正在为三个视图组制作动画(其中包含一个 ImageView 和一个 TextView)。每个按钮我有一个动画,将视图向左平移,同时将 alpha 设置为 0。在该动画之后,还有另一个动画将相同的视图从右侧平移到原始位置,并将 alpha 设置回 1。除了平移和 alpha 动画之外,还有一个视图还具有缩放动画。所有视图都使用不同的计时功能(缓动)。输入动画和输出动画是不同的,一个视图具有不同的缩放计时功能,而 alpha 和平移动画使用相同的功能。第一个动画结束后,我将设置值以准备第二个动画。缩放动画的持续时间也比平移和 alpha 动画短。我将单个动画(翻译和 alpha)放在 AnimatorSet(基本上是动画组)中。这个 AnimatorSet 被放在另一个 AnimatorSet 中以 运行 彼此之后的动画(第一个动画然后比在)。并且这个 AnimatorSet 被放在另一个 AnimatorSet 中,它 运行 同时为所有 3 个按钮设置动画。

抱歉解释得太长了。但是这样你就会明白我是如何尝试将它移植到 iOS 的。这个对于 UIView.animate() 来说太复杂了。如果放入 CAAnimationGroup,CAAnimation 会覆盖委托。据我所知,ViewPropertyAnimator 不允许自定义计时功能,并且无法协调多个动画。

有人知道我可以用它做什么吗?我也很喜欢自定义实现,它会在每个动画滴答时给我一个回调,这样我就可以相应地更新视图。


编辑

Android动画代码:

fun setState(newState: State) {
    if(state == newState) {
        return
    }

    processing = false

    val prevState = state
    state = newState

    val reversed = newState.ordinal < prevState.ordinal

    val animators = ArrayList<Animator>()
    animators.add(getMiddleButtonAnimator(reversed, halfAnimationDone = {
        displayMiddleButtonState()
    }))

    if(prevState == State.TAKE_PICTURE || newState == State.TAKE_PICTURE) {
        animators.add(getButtonAnimator(leftButton, leftButton, leftButton.imageView.width.toFloat(), reversed, halfAnimationDone = {
            displayLeftButtonState()
        }))
    }

    if(prevState == State.TAKE_PICTURE || newState == State.TAKE_PICTURE) {
        animators.add(getButtonAnimator(
            if(newState == State.TAKE_PICTURE) rightButton else null,
            if(newState == State.CROP_PICTURE) rightButton else null,
            rightButton.imageView.width.toFloat(),
            reversed,
            halfAnimationDone = {
                displayRightButtonState(inAnimation = true)
            }))
    }

    val animatorSet = AnimatorSet()
    animatorSet.playTogether(animators)
    animatorSet.start()
}

fun getButtonAnimator(animateInView: View?, animateOutView: View?, maxTranslationXValue: Float, reversed: Boolean, halfAnimationDone: () -> Unit): Animator {
    val animators = ArrayList<Animator>()

    if(animateInView != null) {
        val animateInAnimator = getSingleButtonAnimator(animateInView, maxTranslationXValue, true, reversed)
        if(animateOutView == null) {
            animateInAnimator.addListener(object : AnimatorListenerAdapter() {
                override fun onAnimationStart(animation: Animator?) {
                    halfAnimationDone()
                }
            })
        }
        animators.add(animateInAnimator)
    }

    if(animateOutView != null) {
        val animateOutAnimator = getSingleButtonAnimator(animateOutView, maxTranslationXValue, false, reversed)
        animateOutAnimator.addListener(object : AnimatorListenerAdapter() {
            override fun onAnimationEnd(animation: Animator?) {
                halfAnimationDone()
            }
        })
        animators.add(animateOutAnimator)
    }

    val animatorSet = AnimatorSet()
    animatorSet.playTogether(animators)

    return animatorSet
}

private fun getSingleButtonAnimator(animateView: View, maxTranslationXValue: Float, animateIn: Boolean, reversed: Boolean): Animator {
    val translateDuration = 140L
    val fadeDuration = translateDuration

    val translateValues =
        if(animateIn) {
            if(reversed) floatArrayOf(-maxTranslationXValue, 0f)
            else floatArrayOf(maxTranslationXValue, 0f)
        } else {
            if(reversed) floatArrayOf(0f, maxTranslationXValue)
            else floatArrayOf(0f, -maxTranslationXValue)
        }
    val alphaValues =
        if(animateIn) {
            floatArrayOf(0f, 1f)
        } else {
            floatArrayOf(1f, 0f)
        }

    val translateAnimator = ObjectAnimator.ofFloat(animateView, "translationX", *translateValues)
    val fadeAnimator = ObjectAnimator.ofFloat(animateView, "alpha", *alphaValues)

    translateAnimator.duration = translateDuration
    fadeAnimator.duration = fadeDuration

    if(animateIn) {
        translateAnimator.interpolator = EasingInterpolator(Ease.CUBIC_OUT)
        fadeAnimator.interpolator = EasingInterpolator(Ease.CUBIC_OUT)
    } else {
        translateAnimator.interpolator = EasingInterpolator(Ease.CUBIC_IN)
        fadeAnimator.interpolator = EasingInterpolator(Ease.CUBIC_IN)
    }

    val animateSet = AnimatorSet()
    if(animateIn) {
        animateSet.startDelay = translateDuration
    }
    animateSet.playTogether(translateAnimator, fadeAnimator)

    return animateSet
}

fun getMiddleButtonAnimator(reversed: Boolean, halfAnimationDone: () -> Unit): Animator {
    val animateInAnimator = getMiddleButtonSingleAnimator(true, reversed)
    val animateOutAnimator = getMiddleButtonSingleAnimator(false, reversed)

    animateOutAnimator.addListener(object : AnimatorListenerAdapter() {
        override fun onAnimationEnd(animation: Animator?) {
            halfAnimationDone()
        }
    })

    val animatorSet = AnimatorSet()
    animatorSet.playTogether(animateInAnimator, animateOutAnimator)

    return animatorSet
}

private fun getMiddleButtonSingleAnimator(animateIn: Boolean, reversed: Boolean): Animator {
    val translateDuration = 140L
    val scaleDuration = 100L
    val fadeDuration = translateDuration
    val maxTranslationXValue = middleButtonImageView.width.toFloat()

    val translateValues =
        if(animateIn) {
            if(reversed) floatArrayOf(-maxTranslationXValue, 0f)
            else floatArrayOf(maxTranslationXValue, 0f)
        } else {
            if(reversed) floatArrayOf(0f, maxTranslationXValue)
            else floatArrayOf(0f, -maxTranslationXValue)
        }
    val scaleValues =
        if(animateIn) floatArrayOf(0.8f, 1f)
        else floatArrayOf(1f, 0.8f)
    val alphaValues =
        if(animateIn) {
            floatArrayOf(0f, 1f)
        } else {
            floatArrayOf(1f, 0f)
        }

    val translateAnimator = ObjectAnimator.ofFloat(middleButtonImageView, "translationX", *translateValues)
    val scaleXAnimator = ObjectAnimator.ofFloat(middleButtonImageView, "scaleX", *scaleValues)
    val scaleYAnimator = ObjectAnimator.ofFloat(middleButtonImageView, "scaleY", *scaleValues)
    val fadeAnimator = ObjectAnimator.ofFloat(middleButtonImageView, "alpha", *alphaValues)

    translateAnimator.duration = translateDuration
    scaleXAnimator.duration = scaleDuration
    scaleYAnimator.duration = scaleDuration
    fadeAnimator.duration = fadeDuration

    if(animateIn) {
        translateAnimator.interpolator = EasingInterpolator(Ease.QUINT_OUT)
        scaleXAnimator.interpolator = EasingInterpolator(Ease.CIRC_OUT)
        scaleYAnimator.interpolator = EasingInterpolator(Ease.CIRC_OUT)
        fadeAnimator.interpolator = EasingInterpolator(Ease.QUINT_OUT)
    } else {
        translateAnimator.interpolator = EasingInterpolator(Ease.QUINT_IN)
        scaleXAnimator.interpolator = EasingInterpolator(Ease.CIRC_IN)
        scaleYAnimator.interpolator = EasingInterpolator(Ease.CIRC_IN)
        fadeAnimator.interpolator = EasingInterpolator(Ease.QUINT_IN)
    }

    if(animateIn) {
        val scaleStartDelay = translateDuration - scaleDuration
        val scaleStartValue = scaleValues[0]

        middleButtonImageView.scaleX = scaleStartValue
        middleButtonImageView.scaleY = scaleStartValue

        scaleXAnimator.startDelay = scaleStartDelay
        scaleYAnimator.startDelay = scaleStartDelay
    }

    val animateSet = AnimatorSet()
    if(animateIn) {
        animateSet.startDelay = translateDuration
    }
    animateSet.playTogether(translateAnimator, scaleXAnimator, scaleYAnimator)

    return animateSet
}

编辑 2

这是动画在 Android 上的效果视频:

https://youtu.be/IKAB9A9qHic

这是我认为您正在寻找的动画的开始。如果您不喜欢幻灯片的时间安排,则可以将 UIView.animate 换成 .curveEaseInOut 换成 CAKeyframeAnimation,这样您可以更精细地控制每一帧。您需要为每个要设置动画的视图设置一个 CAKeyFrameAnimation

这是一个 playground,您可以将它复制并粘贴到一个空的 playground 中以查看它的运行情况。

import UIKit
import Foundation
import PlaygroundSupport

class ViewController: UIViewController {

    let bottomBar = UIView()
    let orangeButton = UIButton(frame: CGRect(x: 0, y: 10, width: 75, height: 75))
    let yellow = UIView(frame: CGRect(x: 20, y: 20, width: 35, height: 35))
    let magenta = UIView(frame: CGRect(x: 80, y: 30, width: 15, height: 15))
    let cyan = UIView(frame: CGRect(x: 50, y: 20, width: 35, height: 35))
    let brown = UIView(frame: CGRect(x: 150, y: 30, width:
    15, height: 15))
    let leftBox = UIView(frame: CGRect(x: 15, y: 10, width: 125, height: 75))

    func setup() {

        let reset = UIButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50))
        reset.backgroundColor = .white
        reset.addTarget(self, action: #selector(resetAnimation), for: .touchUpInside)
        self.view.addSubview(reset)

        bottomBar.frame = CGRect(x: 0, y: self.view.frame.size.height - 100, width: self.view.frame.size.width, height: 100)
        bottomBar.backgroundColor = .purple
        self.view.addSubview(bottomBar)

        orangeButton.backgroundColor = .orange
        orangeButton.center.x = bottomBar.frame.size.width / 2
        orangeButton.addTarget(self, action: #selector(orangeTapped(sender:)), for: .touchUpInside)
        orangeButton.clipsToBounds = true
        bottomBar.addSubview(orangeButton)

        yellow.backgroundColor = .yellow
        orangeButton.addSubview(yellow)

        magenta.backgroundColor = .magenta
        magenta.alpha = 0
        orangeButton.addSubview(magenta)

        // Left box is an invisible bounding box to get the effect that the view appeared from nowhere
        // Clips to bounds so you cannot see the view when it has not been animated
        // Try setting to false
        leftBox.clipsToBounds = true
        bottomBar.addSubview(leftBox)

        cyan.backgroundColor = .cyan
        leftBox.addSubview(cyan)

        brown.backgroundColor = .brown
        brown.alpha = 0
        leftBox.addSubview(brown)
    }

    @objc func orangeTapped(sender: UIButton) {

        // Perform animation
        UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut, animations: {

            self.yellow.frame = CGRect(x: -20, y: 30, width: 15, height: 15)
            self.yellow.alpha = 0

            self.magenta.frame = CGRect(x: 20, y: 20, width: 35, height: 35)
            self.magenta.alpha = 1

            self.cyan.frame = CGRect(x: -150, y: 30, width: 15, height: 15)
            self.cyan.alpha = 0

            self.brown.frame = CGRect(x: 50, y: 20, width: 35, height: 35)
            self.brown.alpha = 1

        }, completion: nil)
    }

    @objc func resetAnimation() {
        // Reset the animation back to the start
        yellow.frame = CGRect(x: 20, y: 20, width: 35, height: 35)
        yellow.alpha = 1
        magenta.frame = CGRect(x: 80, y: 30, width: 15, height: 15)
        magenta.alpha = 0
        cyan.frame = CGRect(x: 50, y: 20, width: 35, height: 35)
        cyan.alpha = 1
        brown.frame = CGRect(x: 150, y: 30, width: 15, height: 15)
        brown.alpha = 0
    }

}
let viewController = ViewController()
viewController.view.frame = CGRect(x: 0, y: 0, width: 375, height: 667)
viewController.view.backgroundColor = .blue
viewController.setup()
PlaygroundPage.current.liveView = viewController

所以我一直在使用 CADisplayLink 研究我自己的解决方案。文档是这样描述的 CADisplayLink:

CADisplayLink is a timer object that allows your application to synchronize its drawing to the refresh rate of the display.

它基本上在执行绘图代码时提供回调(因此您可以 运行 您的动画流畅)。

我不打算解释这个答案中的所有内容,因为它会有很多代码,而且大部分应该都很清楚。如果有什么不明白或者你有问题可以在这个答案下面评论。

此解决方案为动画提供了完全的自由,并提供了协调它们的能力。我看了很多 Android 上的 Animator class 并想要类似的语法,以便我们可以轻松地将动画从 Android 移植到 iOS 或其他方式大约。我已经对其进行了几天的测试,并且也消除了一些怪癖。话不多说,让我们看一些代码吧!

这是 Animator class,这是动画的基础结构 classes:

class Animator {
    internal var displayLink: CADisplayLink? = nil
    internal var startTime: Double = 0.0
    var hasStarted: Bool = false
    var hasStartedAnimating: Bool = false
    var hasFinished: Bool = false
    var isManaged: Bool = false
    var isCancelled: Bool = false

    var onAnimationStart: () -> Void = {}
    var onAnimationEnd: () -> Void = {}
    var onAnimationUpdate: () -> Void = {}
    var onAnimationCancelled: () -> Void = {}

    public func start() {
        hasStarted = true

        startTime = CACurrentMediaTime()
        if(!isManaged) {
            startDisplayLink()
        }
    }

    internal func startDisplayLink() {
        stopDisplayLink() // make sure to stop a previous running display link

        displayLink = CADisplayLink(target: self, selector: #selector(animationTick))
        displayLink?.add(to: .main, forMode: .commonModes)
    }

    internal func stopDisplayLink() {
        displayLink?.invalidate()
        displayLink = nil
    }

    @objc internal func animationTick() {

    }

    public func cancel() {
        isCancelled = true
        onAnimationCancelled()
        if(!isManaged) {
            animationTick()
        }
    }
}

它包含所有重要信息,如启动 CADisplayLink、提供停止 CADisplayLink 的能力(当动画完成时)、指示状态的布尔值和一些回调。您还会注意到 isManaged 布尔值。此布尔值表示 Animator 由一个组控制。如果是,该组将提供动画滴答声,并且此 class 不应启动 CADisplayLink

接下来是 ValueAnimator:

class ValueAnimator : Animator {
    public internal(set) var progress: Double = 0.0
    public internal(set) var interpolatedProgress: Double = 0.0

    var duration: Double = 0.3
    var delay: Double = 0
    var interpolator: Interpolator = EasingInterpolator(ease: .LINEAR)

    override func animationTick() {
        // In case this gets called after we finished
        if(hasFinished) {
            return
        }

        let elapsed: Double = (isCancelled) ? self.duration : CACurrentMediaTime() - startTime - delay

        if(elapsed < 0) {
            return
        }

        if(!hasStartedAnimating) {
            hasStartedAnimating = true
            onAnimationStart()
        }

        if(duration <= 0) {
            progress = 1.0
        } else {
            progress = min(elapsed / duration, 1.0)
        }
        interpolatedProgress = interpolator.interpolate(elapsedTimeRate: progress)

        updateAnimationValues()
        onAnimationUpdate()

        if(elapsed >= duration) {
            endAnimation()
        }
    }

    private func endAnimation() {
        hasFinished = true
        if(!isManaged) {
            stopDisplayLink()
        }
        onAnimationEnd()
    }

    internal func updateAnimationValues() {

    }
}

这个class是所有价值动画师的基础class。但如果您想自己进行计算,它也可以用于您自己制作动画。您可能会注意到此处的 InterpolatorinterpolatedProgressInterpolator class 稍后会显示。 class 提供了动画的缓动。这就是 interpolatedProgress 的用武之地。progress 只是从 0.0 到 1.0 的线性进展,但 interpolatedProgress 可能具有不同的缓动值。例如,当 progress 的值为 0.2 时,interpolatedProgress 可能已经有 0.4,具体取决于您将使用的缓动。还要确保使用 interpolatedProgress 来计算正确的值。 ValueAnimator 的第一个子 class 示例如下。

下面是 CGFloatValueAnimator,顾名思义,它对 CGFloat 值进行动画处理:

class CGFloatValueAnimator : ValueAnimator {
    private let startValue: CGFloat
    private let endValue: CGFloat
    public private(set) var animatedValue: CGFloat

    init(startValue: CGFloat, endValue: CGFloat) {
        self.startValue = startValue
        self.endValue = endValue
        self.animatedValue = startValue
    }

    override func updateAnimationValues() {
        animatedValue = startValue + CGFloat(Double(endValue - startValue) * interpolatedProgress)
    }
}

这是一个如何子 class ValueAnimator 的例子,如果你需要其他的,比如双精度数或整数,你可以做更多这样的例子。您只需提供开始值和结束值,Animator 根据 interpolatedProgress 计算当前的 animatedValue 是什么。您可以使用此 animatedValue 来更新您的观点。我会在最后展示一个例子。

因为我已经多次提到 Interpolator,所以我们现在继续Interpolator

protocol Interpolator {
    func interpolate(elapsedTimeRate: Double) -> Double
}

这只是一个您可以自己实现的协议。我将向您展示我自己使用的 EasingInterpolator class 的一部分。如果有人需要,我可以提供更多。

class EasingInterpolator : Interpolator {
    private let ease: Ease

    init(ease: Ease) {
        self.ease = ease
    }

    func interpolate(elapsedTimeRate: Double) -> Double {
        switch (ease) {
            case Ease.LINEAR:
                return elapsedTimeRate
            case Ease.SINE_IN:
                return (1.0 - cos(elapsedTimeRate * Double.pi / 2.0))
            case Ease.SINE_OUT:
                return sin(elapsedTimeRate * Double.pi / 2.0)
            case Ease.SINE_IN_OUT:
                return (-0.5 * (cos(Double.pi * elapsedTimeRate) - 1.0))
            case Ease.CIRC_IN:
                return  -(sqrt(1.0 - elapsedTimeRate * elapsedTimeRate) - 1.0)
            case Ease.CIRC_OUT:
                let newElapsedTimeRate = elapsedTimeRate - 1
                return sqrt(1.0 - newElapsedTimeRate * newElapsedTimeRate)
            case Ease.CIRC_IN_OUT:
                var newElapsedTimeRate = elapsedTimeRate * 2.0
                if (newElapsedTimeRate < 1.0) {
                    return (-0.5 * (sqrt(1.0 - newElapsedTimeRate * newElapsedTimeRate) - 1.0))
                }
                newElapsedTimeRate -= 2.0
                return (0.5 * (sqrt(1 - newElapsedTimeRate * newElapsedTimeRate) + 1.0))

            default:
                return elapsedTimeRate

        }
    }
}

这些只是特定缓动计算的几个例子。我实际上移植了位于此处的 Android 的所有缓动:https://github.com/MasayukiSuda/EasingInterpolator.

在展示示例之前,我还有一个 class 要展示。哪个 class 允许动画师分组:

class AnimatorSet : Animator {
    private var animators: [Animator] = []

    var delay: Double = 0
    var playSequential: Bool = false

    override func start() {
        super.start()
    }

    override func animationTick() {
        // In case this gets called after we finished
        if(hasFinished) {
            return
        }

        let elapsed = CACurrentMediaTime() - startTime - delay
        if(elapsed < 0 && !isCancelled) {
            return
        }

        if(!hasStartedAnimating) {
            hasStartedAnimating = true
            onAnimationStart()
        }

        var finishedNumber = 0
        for animator in animators {
            if(!animator.hasStarted) {
                animator.start()
            }
            animator.animationTick()
            if(animator.hasFinished) {
                finishedNumber += 1
            } else {
                if(playSequential) {
                    break
                }
            }
        }

        if(finishedNumber >= animators.count) {
            endAnimation()
        }
    }

    private func endAnimation() {
        hasFinished = true
        if(!isManaged) {
            stopDisplayLink()
        }
        onAnimationEnd()
    }

    public func addAnimator(_ animator: Animator) {
        animator.isManaged = true
        animators.append(animator)
    }

    public func addAnimators(_ animators: [Animator]) {
        for animator in animators {
            animator.isManaged = true
            self.animators.append(animator)
        }
    }

    override func cancel() {
        for animator in animators {
            animator.cancel()
        }

        super.cancel()
    }
}

如您所见,这里是我设置 isManaged 布尔值的地方。您可以将您制作的多个动画师放入此 class 中以协调它们。因为这个 class 也扩展了 Animator 你也可以放入另一个 AnimatorSet 或多个。默认情况下它 运行 同时播放所有动画,但是如果 playSequential 设置为 true,它将 运行 所有动画按顺序播放。

演示时间:

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.

        let animView = UIView()
        animView.backgroundColor = UIColor.yellow
        self.view.addSubview(animView)

        animView.snp.makeConstraints { maker in
            maker.width.height.equalTo(100)
            maker.center.equalTo(self.view)
        }

        let translateAnimator = CGFloatValueAnimator(startValue: 0, endValue: 100)
        translateAnimator.delay = 1.0
        translateAnimator.duration = 1.0
        translateAnimator.interpolator = EasingInterpolator(ease: .CIRC_IN_OUT)
        translateAnimator.onAnimationStart = {
            animView.backgroundColor = UIColor.blue
        }
        translateAnimator.onAnimationEnd = {
            animView.backgroundColor = UIColor.green
        }
        translateAnimator.onAnimationUpdate = {
            animView.transform.tx = translateAnimator.animatedValue
        }

        let alphaAnimator = CGFloatValueAnimator(startValue: animView.alpha, endValue: 0)
        alphaAnimator.delay = 1.0
        alphaAnimator.duration = 1.0
        alphaAnimator.interpolator = EasingInterpolator(ease: .CIRC_IN_OUT)
        alphaAnimator.onAnimationUpdate = {
            animView.alpha = alphaAnimator.animatedValue
        }

        let animatorSet = AnimatorSet()
//        animatorSet.playSequential = true // Uncomment this to play animations in order
        animatorSet.addAnimator(translateAnimator)
        animatorSet.addAnimator(alphaAnimator)

        animatorSet.start()
    }

}

我认为大部分内容不言而喻。我创建了一个转换 x 并淡出的视图。对于每个动画,您实现 onAnimationUpdate 回调以更改视图中使用的值,例如在本例中的平移 x 和 alpha。

注意:与Android矛盾的是​​,这里的持续时间和延迟是以秒为单位而不是毫秒。

我们现在正在使用此代码,效果很好!我已经在我们的 Android 应用程序中编写了一些动画内容。我可以轻松地将动画移植到 iOS,只需进行一些最少的重写,并且动画效果完全一样!我可以复制我的问题中编写的代码,将 Kotlin 代码更改为 Swift,应用 onAnimationUpdate,将持续时间和延迟更改为秒,动画效果非常好。

我想将其作为开源库发布,但我还没有这样做。当我发布它时,我会更新这个答案。

如果您对代码或其工作原理有任何疑问,请随时提问。