RxJava驱动的安卓动画

原文:Android animations powered by RxJava。 

在安卓中要对一个对象使用动画是非常简单的事情,尤其是使用ViewPropertyAnimator。如果在加上RxJava你就可以和其它的动画一起应用链式的动画。

注意:本文的目的是向你演示在不用写过多嵌套代码的情况下,如何把RxJava的特性和动画结合起来以制造出很棒的用户界面。所以要完全理解这个过程需要RxJava的基础知识,但是即便没有此知识你也能了从中了解RxJava的灵活与强大。本文的代码使用的是Kotlin,而不是Java。

如果想看看我们如何在自己的app中使用的话,请看PDF Viewer

属性动画基础

整篇文章都会用到ViewPropertyAnimatorCompat,它是通过调用ViewCompat.animate(targetView)实现的。这个类优化了属性动画(ObjectAnimator),语法简单方便并提供了非常灵活的视图动画。

关于ViewPropertyAnimator请看这篇文章https://segmentfault.com/a/1190000004411201  ,它会告诉你ViewPropertyAnimator的来龙去脉 - 译者注。

让我们来看看如何用它来对一个简单的View做动画。我们将缩小按钮(缩小至0)一旦动画结束把它从父view移除。

ViewCompat.animate(someButton)
    .scaleX(0f)                         // Scale to 0 horizontally
    .scaleY(0f)                         // Scale to 0 vertically
    .setDuration(300)                   // Duration of the animation in milliseconds.
    .withEndAction { removeView(view) } // Called when the animation ends successfully.

这个例子非常简单,但是在更复杂的情况下可能就会有点乱了,尤其是有嵌套withEndAction{}的情况下(你也可以使用setListener())。

引入RxJava

使用RxJava,我们将这个listener的嵌套转换成事件,发送给observer。那么对每个要使用动画的view我们调用onNext(view)让它在下游处理。

一种选择是创建简单的自定义操作符来处理各种动画。比如横向或者纵向移动view的动画。

接下来的例子在实践中很少遇到,但可以演示RxJava动画的强大。假如我们有两个矩形,如下图所示,在开始的时候左边的矩形有一组圆。一旦点击了“Animate”按钮,我们希望这些圆从左边的矩形移动到右边。如果再次按下按钮,做相反的动画。圆在相等的时间间隔内有序的移动。

ss1-ca5334b8.png

让我们创建一个接收一个View的操作符,在里面执行动画,并把它传给subscriber的onNext方法。这样RxJava的流就将等到动画完成才继续将view传递到下游。你也可以实现一个操作符让动画触发后立即传递view。

import android.support.v4.view.ViewCompat
import android.view.View
import android.view.animation.Interpolator
import rx.Observable
import rx.Subscriber
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class TranslateViewOperator(private val translationX: Float,
                            private val translationY: Float,
                            private val duration: Long,
                            private val interpolator: Interpolator) : Observable.Operator<View, View> {
    // Counts the number of animations in progress. 
    // Used for properly propagating onComplete() call to the subscriber.
    private val numberOfRunningAnimations = AtomicInteger(0)
    // Indicates whether this operator received the onComplete() call or not.
    private val isOnCompleteCalled = AtomicBoolean(false)
    override fun call(subscriber: Subscriber<in View>) = object : Subscriber<View>() {
        override fun onError(e: Throwable?) {
            // In case of onError(), just pass it down to the subscriber.
            if (!subscriber.isUnsubscribed) {
                subscriber.onError(e)
            }
        }
        override fun onNext(view: View) {
            // Don't start animation if the subscriber has unsubscribed.
            if (subscriber.isUnsubscribed) return
            // Run the animation.
            numberOfRunningAnimations.incrementAndGet()
            ViewCompat.animate(view)
                .translationX(translationX)
                .translationY(translationY)
                .setDuration(duration)
                .setInterpolator(interpolator)
                .withEndAction {
                    numberOfRunningAnimations.decrementAndGet()
                    // Once the animation is done, check if the subscriber is still subscribed
                    // and pass the animated view to onNext().
                    if (!subscriber.isUnsubscribed) {
                        subscriber.onNext(view)
                        // If we received the onComplete() event sometime while the animation was running,
                        // wait until all animations are done and then call onComplete() on the subscriber.
                        if (numberOfRunningAnimations.get() == 0 && isOnCompleteCalled.get()) {
                            subscriber.onCompleted()
                        }
                    }
                }
        }
        override fun onCompleted() {
            isOnCompleteCalled.set(true)
            // Call onComplete() immediately if all animations are finished.
            if (!subscriber.isUnsubscribed && numberOfRunningAnimations.get() == 0) {
                subscriber.onCompleted()
            }
        }
    }
}

现在我们就可以轻易的为持有这些圆和矩形的ViewGroup创建移动view的方法了(为了方便我们使用Kotlin扩展方法):

fun Observable<View>.translateView(translationX: Float, 
                                   translationY: Float, 
                                   duration: Long, 
                                   interpolator: Interpolator): Observable<View> =
            lift<View> (TranslateViewOperator(translationX, translationY, duration, interpolator))

我们把圆放在list中,而举行作为单独的变量。记住这只是举例说明。

fun init() {
    rectangleLeft = RectangleView(context, Color.BLACK)
    rectangleRight = RectangleView(context, Color.BLACK)
    addView(rectangleLeft)
    addView(rectangleRight)
    // Add 10 circles.
    for (i in 0..9) {
        val cv = CircleView(context, Color.RED);
        circleViews.add(cv)
        addView(cv)
    }
}
// onLayout() and other code omitted..

让我们创建一个开始动画的方法。我们可以通过zip Observable和一个timer Observable以达到按时间间隔获取圆的效果。

// Subscription to circle views movement animations.
private var animationSubscription: Subscription? = null
override fun startAnimation() {
    // First, unsubscribe from previous animations.
    animationSubscription?.unsubscribe()
    // Timer observable that will emit every half second.
    val timerObservable = Observable.interval(0, 500, TimeUnit.MILLISECONDS)
    // Observable that will emit circle views from the list.
    val viewsObservable = Observable.from(circleViews)
            // As each circle view is emitted, stop animations on it.
            .doOnNext { v -> ViewCompat.animate(v).cancel() }
            // Just take those circles that are not already in the right rectangle.
            .filter { v -> v.translationX < rectangleRight.left }
    // First, zip the timer and circle views observables, so that we get one circle view every half a second.
    animationSubscription = Observable.zip(viewsObservable, timerObservable) { view, time -> view }
            // As each view comes in, translate it so that it ends up inside the right rectangle.
            .translateView(rectangleRight.left.toFloat(), rectangleRight.top.toFloat(), ANIMATION_DURATION_MS, DecelerateInterpolator())
            .subscribe()
}

现在让我们来实现reverseAnimation()方法:

override fun reverseAnimation() {
    // First, unsubscribe from previous animations.
    animationSubscription?.unsubscribe()
    // Timer observable that will emit every half second.
    val timerObservable = Observable.interval(0, 500, TimeUnit.MILLISECONDS)
    // Observable that will emit circle views from the list but in reverse order, 
    // so that the last one that was animated is now a first one to be animated.
    val viewsObservable = Observable.from(circleViews.asReversed())
            // As each circle view is emitted, stop animations on it.
            .doOnNext { v -> ViewCompat.animate(v).cancel() }
            // Just take those circles that are not already in the left rectangle.
            .filter { v -> v.translationX > rectangleLeft.left }
    // First, zip the timer and circle views observables, so that we get one circle view every half a second.
    animationSubscription = Observable.zip(viewsObservable, timerObservable) { view, time -> view }
            // As each view comes in, translate it so that it ends up inside the left rectangle.
            .translateView(rectangleLeft.left.toFloat(), rectangleLeft.top.toFloat(), ANIMATION_DURATION_MS, AccelerateInterpolator())
            .subscribe()
}

结果和预期的一致。

gif1-26db2be8.gif

在此之上的扩展具有无限可能。比如,通过移除timer你就可以同时移除掉所有view。你还可以在动画结束时把每个view传递到数据流。

这很酷,但是并没有什么实际价值。并且,创建自定义操作符并不总是好事,可能会遇到问题比如improper backpressure handling

实际生活中,绝大多数时候我们需要一种稍微不同的方式来处理动画。

认识Completable

CompletableRxJava 1.1.1被引入,那么什么是Completable呢?

摘自RxJava wiki:

我们可以把Completable对象看成阉割版的Observable,它只发射终点事件,onError和onCompleted;它们看起来可能像一个Observable.empty(),但是和empty()不同的是Completable是一个active类。Completable限制了在自己被订阅时的副作用,这也是它的主要目的。

我们可以使用Completable作为执行动画的一种方式,一旦动画完成调用onComplete()。那时就可以执行其它动画或者任意的行为。

所以现在我们用阉割版的Observable而不用操作符,所以我们在动画结束的时候我们也不会发送视图流,而只是通知observer请求的动画已经结束了。

让我们来创建另一个更实际的例子。假设我们有一个带有icon的toolbar,我们想要提供一个能完成下面任务的asetMenuItems()方法:将当前的item折叠至toolbar的最左边;将它们缩小直至消失;把它们从父view移除;向父view添加新的item缩小至0;把它们放大;最后,在toolbar上把它们展开。

这里我们将使用FloatingActionButton,只是为了避免自定义view的代码。为此,导入com.android.support:design:24.2.1库。

我们将从Completable.OnSubscribe的实现创建Completable。这种实现对我们的场景更具有定制性。首先我们创建一个将接收一组横向或纵向展开和折叠的FAB的Completable。假设所有的FAB都是相同大小。

import android.support.design.widget.FloatingActionButton
import android.support.v4.view.ViewCompat
import android.view.animation.Interpolator
import rx.Completable
import rx.CompletableSubscriber
import java.util.concurrent.atomic.AtomicInteger
class ExpandViewsOnSubscribe(private val views: List<FloatingActionButton>,
                             private val animationType: AnimationType,
                             private val duration: Long,
                             private val interpolator: Interpolator,
                             private val paddingPx: Int): Completable.OnSubscribe {
    enum class AnimationType {
        EXPAND_HORIZONTALLY, COLLAPSE_HORIZONTALLY,
        EXPAND_VERTICALLY, COLLAPSE_VERTICALLY
    }
    lateinit private var numberOfAnimationsToRun: AtomicInteger
    override fun call(subscriber: CompletableSubscriber) {
        if (views.isEmpty()) {
            subscriber.onCompleted()
            return
        }
        // We need to run as much as animations as there are views.
        numberOfAnimationsToRun = AtomicInteger(views.size)
        // Assert all FABs are the same size, we could count each item size if we're making
        // an implementation that possibly expects different-sized items.
        val fabWidth = views\[0\].width
        val fabHeight = views\[0\].height
        val horizontalExpansion = animationType == AnimationType.EXPAND_HORIZONTALLY
        val verticalExpansion = animationType == AnimationType.EXPAND_VERTICALLY
        // Only if expanding horizontally, we'll move x-translate each of the FABs by index * width.
        val xTranslationFactor = if (horizontalExpansion) fabWidth else 0
        // Only if expanding vertically, we'll move y-translate each of the FABs by index * height.
        val yTranslationFactor = if (verticalExpansion) fabHeight else 0
        // Same with padding.
        val paddingX = if (horizontalExpansion) paddingPx else 0
        val paddingY = if (verticalExpansion) paddingPx else 0
        for (i in views.indices) {
            ViewCompat.animate(views\[i\])
            .translationX(i * (xTranslationFactor.toFloat() + paddingX))
            .translationY(i * (yTranslationFactor.toFloat() + paddingY))
            .setDuration(duration)
            .setInterpolator(interpolator)
            .withEndAction {
                // Once all animations are done, call onCompleted().
                if (numberOfAnimationsToRun.decrementAndGet() == 0) {
                    subscriber.onCompleted()
                }
            }
        }
    }
}

然后现在我们创建一个从这个Completable.OnSubscribe返回Completable的方法:

private val INTERPOLATOR = AccelerateDecelerateInterpolator()
private val DURATION_MS = 300L
private val PADDING_PX = 32
// Holds current menu items.
private var currentItems = mutableListOf<FloatingActionButton>()
fun expandMenuItemsHorizontally(items: MutableList<FloatingActionButton>): Completable =
        Completable.create(ExpandViewsOnSubscribe(items, EXPAND_HORIZONTALLY, 300L, AccelerateDecelerateInterpolator(), 32))
fun collapseMenuItemsHorizontally(items: MutableList<FloatingActionButton>): Completable =
        Completable.create(ExpandViewsOnSubscribe(items, COLLAPSE_HORIZONTALLY, 300L, AccelerateDecelerateInterpolator(), 32))

如果我们在视图的开始添加一些示例item,我们就可以测试效果了:

override fun startAnimation() {
    expandMenuItemsHorizontally(currentItems).subscribe()
}
override fun reverseAnimation() {
    collapseMenuItemsHorizontally(currentItems).subscribe()
}

这个例子中,我们将把它们添加到ViewGroup的开始,看看折叠和展开是如何工作的:

gif2-bf533473.gif

链式动画

同样的模式,让我们实现执行缩放和旋转的Completable.OnSubscribe类。做法和折叠与展开是一样的,只是动画不同。代码就省略了。

最后,下面是我们已经准备好了的控件方法:

fun expandMenuItemsHorizontally(items: MutableList<FloatingActionButton>): Completable =
        Completable.create(ExpandViewsOnSubscribe(items, EXPAND_HORIZONTALLY, 300L, AccelerateDecelerateInterpolator(), 32))
fun collapseMenuItemsHorizontally(items: MutableList<FloatingActionButton>): Completable =
        Completable.create(ExpandViewsOnSubscribe(items, COLLAPSE_HORIZONTALLY, 300L, AccelerateDecelerateInterpolator(), 32))
fun rotateMenuItemsBy90(items: MutableList<FloatingActionButton>): Completable =
        Completable.create(RotateViewsOnSubscribe(items, ROTATE_TO_90, 300L, DecelerateInterpolator()))
fun rotateMenuItemsToOriginalPosition(items: MutableList<FloatingActionButton>): Completable =
        Completable.create(RotateViewsOnSubscribe(items, ROTATE_TO_0, 300L, DecelerateInterpolator()))
fun scaleDownMenuItems(items: MutableList<FloatingActionButton>): Completable =
        Completable.create(ScaleViewsOnSubscribe(items, SCALE_DOWN, 400L, DecelerateInterpolator()))
fun scaleUpMenuItems(items: MutableList<FloatingActionButton>): Completable =
        Completable.create(ScaleViewsOnSubscribe(items, SCALE_UP, 400L, DecelerateInterpolator()))
fun removeMenuItems(items: MutableList<FloatingActionButton>): Completable = Completable.fromAction {
    for (item in items) {
        removeView(item)
    }
}
fun addItemsScaledDownAndRotated(items: MutableList<FloatingActionButton>): Completable = Completable.fromAction {
    this.currentItems = items
    for (item in items) {
        item.scaleX = 0f
        item.scaleY = 0f
        item.rotation = 90f
        addView(item)
    }
}

现在我们就能实现setMenuItems()了:

fun setMenuItems(newItems: MutableList<FloatingActionButton>) {
    collapseMenuItemsHorizontally(currentItems)
            .andThen(rotateMenuItemsBy90(currentItems))
            .andThen(scaleDownMenuItems(currentItems))
            .andThen(removeMenuItems(currentItems))
            .andThen(addItemsScaledDownAndRotated(newItems))
            .andThen(scaleUpMenuItems(newItems))
            .andThen(rotateMenuItemsToOriginalPosition(newItems))
            .andThen(expandMenuItemsHorizontally(newItems))
            .subscribe()
}

这是设置了新item之后的效果:

gif3-af6205e7.gif

限制

记住这里我们不能使用mergeWith()去让动画一起执行因为它们在同一个view上被调用。这意味着监听者会彼此覆盖因此融合不可能完成。因为它要等两个Completable完成。如果你在不同的view上调用它则可以正常使用。Completable将等两个动画完成之后再调用onComplete()。

解决这个问题的方法之一是实现一个OnSubscribe,它允许我们在一个view上执行多个动画。比如,可以按照我们演示的方法实现RotateAndScaleViewOnSubscribe。

我们是如何在框架中使用它的

With our new toolbar, introduced inPSPDFKit 2.6 for Android, came the need for chained animations since submenus were added. Here's an example:

我们PSPDFKit安卓2.6版引入的新toolbar就有对链式动画的需求,因为子菜单是动态添加的。下面是例子:

gif4-05d604b2 (1).gif

这种实现让我们的控制逻辑干净灵活,跟我们在本文中描述的类似:

subMenuToClose.hideMenuItems(true)
    .andThen(closeSubmenu(subMenuToClose))
    .andThen(openSubmenu(subMenuToOpen))
    .andThen(subMenuToOpen.showMenuItems(true))
    .subscribe()

下面是放慢动画的样子:

andThen_c-68a12dad (1).gif

因为item和submenu是不同的视图,所以我们可以用一个construct同时隐藏menu item和关闭submenu:

subMenuToClose.hideMenuItems(true).mergeWith(closeSubmenu(subMenuToClose))
              .andThen(openSubmenu(subMenuToOpen).mergeWith(subMenuToOpen.showMenuItems(true)))
              .subscribe()

Or one that would do it all together:

subMenuToClose.hideMenuItems(true)
    .mergeWith(closeSubmenu(subMenuToClose))
    .mergeWith(openSubmenu(subMenuToOpen))
    .mergeWith(subMenuToOpen.showMenuItems(true))
    .subscribe()

总结

你可以把本文当成对RxJava和动画结合所能产生的可能性的简要讨论。能做的事情基本可以说是无限的,但是有时需要点创造力而且这个过程中还可能会产生一些头疼的问题。

来自:RxJava