InstaMaterial:正确处理RecyclerView动画

英文原文:InstaMaterial - RecyclerView animations done right (thanks to Android Dev Summit!) 

我们生活在一个app不仅要能用还要流畅和好看的年代。不同与几年前,我们对ListView adapter唯一要做的事情就是调用notifyDataSetChanged(),屏幕一闪,显示新的数据,完事。

今天,在RenderThread的时代,MaterialDesign动画以及过渡效果的app应该完全展示出所发生的事情。用户应该看到什么时候它的集合发生了改变或者什么时候新的元素出现(或者被移除)。


几周前,我们看到了(现场或者线上)一个伟大的安卓大会 - Android Dev Summit。在这两天的深入技术会议中我们可以看到安卓工程师团队推出了的新东西 - Android Studio 2.0,新的Gradle 插件, Instant run功能,新的官方模拟器等等。

顺便说一句,如果你错过了,我强烈建议去观看整个播放列表 - 这里面有很多关于安卓开发,工具以及解决方案的会议。

其中的一个视频- RecyclerView Animations and Behind the Scenes 正是写本文的原因。Chet Haase 和 Yigit Boyar 过了一遍RecyclerView的item动画并演示了如何正确的做这件事。对于要学习如何让RecyclerView更有吸引力更好看来说,这个视频是个很好的开始。

https://youtu.be/imsr8NrIAMs?list=PLWz5rJ2EKKc_Tt7q77qwyKRgytF1RzRx8 

InstaMaterial 遇到 RecyclerView使用指南  

今天我们将从特定的角度去看看RecyclerView动画(很快我将试试正式的去深入探讨整个RecyclerView)。

我们想要整理的InstaMaterial源码在这个 commit 中(最新的前几个版本已经根据以下的描述进行了更新。)

还有一点同样重要 - 从一个用户的角度来说,这个版本没有任何改变。但是从代码的角度来说,我们将更明智(至少更干净)。

期望的效果

我们想要重建的代码负责两个相似的操作:

  1. like操作:点击item主图片

  2. like欢操作:点击喜欢按钮

这些动画应该在RecyclerView中被触发。

  1. 出场动画-当对象首次添加的时候,feed item从底部滑出。

  2. 大like动画 - 当用户点击主图片的时候,圆形背景的心形播放动画。

  3. like按钮动画 - 用户点击like按钮或者点击主图片(这样就是两个动画被播放)心形旋转并被填充。

这里是上面描述的动画(从最近的app录制过来):

Appearance animation

add_anim.gif

大的like动画

big_like_anim.gif

like按钮动画

small_like_anim.gif

代码

以前我们的动画直接在 RecyclerView.Adapter的子类 FeedAdapter里实现的,一切运行正常,那这个方法到底有什么问题呢?

  1. RecyclerView.Adapter并不是为动画而设计的。根据 文档
    Adapters提供了app专用数据到视图的绑定。adapter已经有足够多的绑定代码,如果再加上动画代码将加倍。

  2. 在adapter中处理动画我们需要考虑如何结束它们,恰当的处理view的回收,确保它们在正确的时间准时播放以及更多的事情。所有的事情都靠我们自己。

  3. 虽然单个item动画好处理,但是对象间的互动(移动/交换item,当新的对象显示或者消失时更新item的位置)则是更复杂的事情。

  4. RecyclerView的发明者为我们提供了官方的解决方案:RecyclerView.ItemAnimator:
    这个类定义了当adapter变化时,发生在item上的动画。
    它处理了上面提到的所有情况。因此我们可以更多的去考虑动画的质量,而不是它们在滚动周期中该如何正确处理的逻辑。

让我们再次看看 FeedAdapter 。

这几行的代码是不应该在这里的:

private static final DecelerateInterpolator DECCELERATE_INTERPOLATOR = new DecelerateInterpolator();    
private static final AccelerateInterpolator ACCELERATE_INTERPOLATOR = new AccelerateInterpolator();    
private static final OvershootInterpolator OVERSHOOT_INTERPOLATOR = new OvershootInterpolator(4);    
private static final int ANIMATED_ITEMS_COUNT = 2;

interpolators.java hosted with ❤ by GitHub

private boolean showLoadingView = false;

showLoadingView.java hosted with ❤ by GitHub

我们需要控制什么时候item动画什么时候不动画(item应该在第一次显示的时候动画,而不是在activity恢复的时候)。

private final Map<RecyclerView.ViewHolder, AnimatorSet> likeAnimations = new HashMap<>();

likeAnimations.java hosted with ❤ by GitHub

我们应该把动画保存在某个位置,以防我们需要在回收的时候检查它们是否还在运行或者等待被取消。

private void runEnterAnimation(View view, int position) {
    if (!animateItems || position >= ANIMATED_ITEMS_COUNT - 1) {
        return;
    }
    if (position > lastAnimatedPosition) {
        lastAnimatedPosition = position;
        view.setTranslationY(Utils.getScreenHeight(context));
        view.animate()
                .translationY(0)
                .setInterpolator(new DecelerateInterpolator(3.f))
                .setDuration(700)
                .start();
    }
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
    runEnterAnimation(viewHolder.itemView, position);
    //...
}

runEnterAnimation.java hosted with ❤ by GitHub

这里,我们在每次view被绑定的时候运行runEnterAnimation,并检查当前是否是应该这么做(item动画只能有一次)。鉴于我们描述的场景,方法的命名可能有些迷惑。

@Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
    //...
    bindLoadingFeedItem(holder);
}
private void bindDefaultFeedItem(int position, CellFeedViewHolder holder) {
    //...
    updateLikesCounter(holder, false);
    updateHeartButton(holder, false);
    holder.btnComments.setTag(position);
    holder.btnMore.setTag(position);
    holder.ivFeedCenter.setTag(holder);
    holder.btnLike.setTag(holder);
    if (likeAnimations.containsKey(holder)) {
        likeAnimations.get(holder).cancel();
    }
    resetLikeAnimationState(holder);
}

onBindViewHolder.java hosted with ❤ by GitHub

在onBindViewHolder()的某个时刻,如果动画已经运行,那么我们取消它。这是因为view可能被回收而我们不知道它们是否已经完成。

updateLikesCounter() 和 updateHeartButton()方法负责两种情况下(动画与静态)内容的设置。

我们的代码也有一个问题。

我们把position传递给按钮:

holder.btnComments.setTag(position);    
holder.btnMore.setTag(position);

holder.java hosted with ❤ by GitHub

让它在后面的onClick()方法中获得:

@Override
public void onClick(View view) {
//...
    if (viewId == R.id.btnComments) {
    //...
    } else if (viewId == R.id.btnMore) {
        if (onFeedItemClickListener != null) {
            onFeedItemClickListener.onMoreClick(view, (Integer) view.getTag());
        }
    }
//...
}

onClick.java hosted with ❤ by GitHub

这个position索引并非总是准确的。尤其是这个position用于这两个情形时:放置上下文菜单到屏幕的正确位置和把adapter的item传递给它的时候(好吧,理论上讲是这种情况)。

因为RecyclerView可以通过异步的方式更新数据(item视图可以不用更新数据就被移除 - 比如notifyItemMoved()),所以有可能我们的position指向的是错误的数据。

这非常类似于 Yigit Boyar所讨论的:

Adapter position

我们不能假设item position是final的(这张幻灯片中的代码就会导致问题)。

所以我们应该转使用RecyclerView.ViewHolder的两个方法:

新的实现

让我们从头再来。我们的Feed将由这些部分组成:

  • FeedItemAnimator继承于DefaultItemAnimator(而它继承于RecyclerView.ItemAnimator)。它为我们提供了RecyclerView执行的默认动画(主要是淡入和淡出),并且这个动画可以在我们认为重要的地方做相关的继承(意思应该是重写相关方法吧)。

  • LinearLayoutManager - 跟前面一样,让feed看起来像一个标准的列表。

  • FeedAdapter - 绑定数据 (并且只做这件事).

FeedItemAnimator

 FeedItemAnimator的完整代码。

同时这里的FeedItemAnimator中我们有一个更有趣的代码:

@Override    
public boolean canReuseUpdatedViewHolder(RecyclerView.ViewHolder viewHolder) {    
    return true;    
}

canReuseUpdatedViewHolder.java hosted with ❤ by GitHub

注:这个方法是Android Support Library 23.1中加入的,参见:Android Support Library 23.1的变化 一文。

在我们播放RecyclerView item动画的时候,我们有一次让RecyclerView保持item的前一个ViewHolder 不变并提供新的ViewHolder来过渡的机会(我们的RecyclerView中只有新的ViewHolder才会可见)。但是我们为布局写了一个自定义的item animator ,所以我们应该使用一样的ViewHolder,并手动动画内容变化。这就是为什么我们的方法返回true的原因。

@NonNull
@Override
public ItemHolderInfo recordPreLayoutInformation(@NonNull RecyclerView.State state,
                                                 @NonNull RecyclerView.ViewHolder viewHolder,
                                                 int changeFlags, @NonNull List<Object> payloads) {
    if (changeFlags == FLAG_CHANGED) {
        for (Object payload : payloads) {
            if (payload instanceof String) {
                return new FeedItemHolderInfo((String) payload);
            }
        }
    }
    return super.recordPreLayoutInformation(state, viewHolder, changeFlags, payloads);
}

recordPreLayoutInformation.java hosted with ❤ by GitHub

当我们调用notifyItemChanged() 方法时,我们可以传入额外的参数帮助我们决定该执行什么什么动画。

FeedAdapter的例子:

  • notifyItemChanged(adapterPosition, ACTION_LIKE_IMAGE_CLICKED);

  • notifyItemChanged(adapterPosition, ACTION_LIKE_BUTTON_CLICKED);

recordPreLayoutInformation() 方法用于在数据改变之前缓存数据。RecyclerView调用onBindViewHolder()(adapter中)然后ItemAnimator调用recordPostLayoutInformation() 缓存数据。

正因为这些操作我们才能得到item改变前后的状态。

最后是调用animateChange()方法,并传入前后ItemHolderInfo对象。如下:

@Override
public boolean animateChange(@NonNull RecyclerView.ViewHolder oldHolder,
                             @NonNull RecyclerView.ViewHolder newHolder,
                             @NonNull ItemHolderInfo preInfo,
                             @NonNull ItemHolderInfo postInfo) {
    cancelCurrentAnimationIfExists(newHolder);
    if (preInfo instanceof FeedItemHolderInfo) {
        FeedItemHolderInfo feedItemHolderInfo = (FeedItemHolderInfo) preInfo;
        FeedAdapter.CellFeedViewHolder holder = (FeedAdapter.CellFeedViewHolder) newHolder;
        animateHeartButton(holder);
        updateLikesCounter(holder, holder.getFeedItem().likesCount);
        if (FeedAdapter.ACTION_LIKE_IMAGE_CLICKED.equals(feedItemHolderInfo.updateAction)) {
            animatePhotoLike(holder);
        }
    }
    return false;
}

animateChange.java hosted with ❤ by GitHub

我们已经清楚的看到心形按钮动画总是被触发,而大图片动画只在用户点击feed图片的时候被触发。这正是我们想要的效果。


第二件事-入场动画。它应该在我们第一次看见列表的时候被触发。如何处理呢?如下:

@Override
public boolean animateAdd(RecyclerView.ViewHolder viewHolder) {
    if (viewHolder.getItemViewType() == FeedAdapter.VIEW_TYPE_DEFAULT) {
        if (viewHolder.getLayoutPosition() > lastAddAnimatedItem) {
            lastAddAnimatedItem++;
            runEnterAnimation((FeedAdapter.CellFeedViewHolder) viewHolder);
            return false;
        }
    }
    dispatchAddFinished(viewHolder);
    return false;
}

animateAdd.java hosted with ❤ by GitHub

当FeedAdapter触发 notifyItemRangeInserted()的时候, 这个 RecyclerView.ItemAnimator的方法将被调用。另一个方法就是调用notifyItemInserted()。

还有什么?

@Override
public void endAnimation(RecyclerView.ViewHolder item) {
    super.endAnimation(item);
    cancelCurrentAnimationIfExists(item);
}
@Override
public void endAnimations() {
    super.endAnimations();
    for (AnimatorSet animatorSet : likeAnimationsMap.values()) {
        animatorSet.cancel();
    }
}

endAnimation.java hosted with ❤ by GitHub

实现这两个方法是很有必要的。这样当item视图从屏幕上出现的时候,RecyclerView能够停止item视图上的动画(同时也将准备好回收)。

另外这两个方法也值得一提:

今天就是这么多。我们更新过的FeedAdapter比之前少了200行代码,同时只负责数据和视图的绑定。这里是它完整的代码

源码

最新版本的InstaMaterial源代码可在Github repository上得到。

作者

Miroslaw Stanek

如果你喜欢,你可以分享给你的粉丝或者 关注我! 

写于2015年12月10日