需求背景
最近主要在做相册模块的工作,接到一个需求是用户可以切换相册布局的排列方式,比如每行3个或者是每行5个这种。因为我的相册模块是使用RecyclerView+GridLayoutManager做的,所以切换每行排列个数时需要调用GridLayoutManager.setSpanCount方法即可。
但是如果光是这么做会发现它的变化很生硬,没有中间的过度动画,从产品层面来说,就有可能导致用户浏览的视线丢失,所以我们需要添加一个过渡动画来引导用户,效果类似于Google Photo的切换排列效果,效果如下:
ezgif-5-7ab69fc25d.gif
刚接到这个需求的时候我是懵逼的,因为我对于RecyclerView动画的理解,只停留在ItemAnimator的animateMove()、animateChange()的程度上,大概也就只能定义一个在item改变的动画,但是这种动画得怎么做啊?
思考分析
在SpanCount改变的时候有这么多Item要变化,而且Item之间是要互相影响的,如果这种动画要我们完全隔离RecyclerView来做一定是一个浩大的工程,而且容易出Bug,所以我一开始的方案就锁定在要使用RecyclerView支持的方法来做。
我首先想到的是:RecyclerView在adapter调用notifyDataSetMove等方法的时候不是本事就会做动画么?如果我在切换SpanCount的时候随便调用一下notifyItemChange会不会自动就把动画做了。我尝试做了一下,效果如下:
ezgif-5-d00dc6d23a.gif哎哟,这个动画跟我们最终想要基本一致,没想到一上手就解决了一半,我真是个天才!
但是我们可以看到,所有的Item在一开始做动画的时候都变小了,大概是因为设置了SpanCount,重新计算了每个Item的大小导致了这种现象,我们想要的效果是在做动画的同时缩小Item的大小,如果我们能做到这一点,那么整个需求就完成了。
我又仔细想了一会,看了一下ItemAnimator这个类中的一些方法,好像并没有哪里支持Item变小动画的,而且我心里又多了一个疑问:为什么setSpanCount可以配合notifyItemChange来做动画,动画不应该是调用notifyItemChange来控制的么,为什么数据源没改变却做了一个动画?别看现在进展很快,但是疑问越来越多,也越来越难解决。
源码探索
现在的情况光靠我现有的知识是无法解决的,我首先想到的是上网搜有没有类似的文章,搜了一圈发现果然没有,那只能自己去源码中寻找答案了。
瞎找的过程就不赘述了,我最后把目标锁定在了RecyclerView#dispatchLayout()这个方法上:
/**
* Wrapper around layoutChildren() that handles animating changes caused by layout.
* Animations work on the assumption that there are five different kinds of items
* in play:
* PERSISTENT: items are visible before and after layout
* REMOVED: items were visible before layout and were removed by the app
* ADDED: items did not exist before layout and were added by the app
* DISAPPEARING: items exist in the data set before/after, but changed from
* visible to non-visible in the process of layout (they were moved off
* screen as a side-effect of other changes)
* APPEARING: items exist in the data set before/after, but changed from
* non-visible to visible in the process of layout (they were moved on
* screen as a side-effect of other changes)
* The overall approach figures out what items exist before/after layout and
* infers one of the five above states for each of the items. Then the animations
* are set up accordingly:
* PERSISTENT views are animated via
* {@link ItemAnimator#animatePersistence(ViewHolder, ItemHolderInfo, ItemHolderInfo)}
* DISAPPEARING views are animated via
* {@link ItemAnimator#animateDisappearance(ViewHolder, ItemHolderInfo, ItemHolderInfo)}
* APPEARING views are animated via
* {@link ItemAnimator#animateAppearance(ViewHolder, ItemHolderInfo, ItemHolderInfo)}
* and changed views are animated via
* {@link ItemAnimator#animateChange(ViewHolder, ViewHolder, ItemHolderInfo, ItemHolderInfo)}.
*/
void dispatchLayout() {
if (mAdapter == null) {
Log.e(TAG, "No adapter attached; skipping layout");
// leave the state in START
return;
}
if (mLayout == null) {
Log.e(TAG, "No layout manager attached; skipping layout");
// leave the state in START
return;
}
mState.mIsMeasuring = false;
if (mState.mLayoutStep == State.STEP_START) {
dispatchLayoutStep1();
mLayout.setExactMeasureSpecsFrom(this);
dispatchLayoutStep2();
} else if (mAdapterHelper.hasUpdates() || mLayout.getWidth() != getWidth()
|| mLayout.getHeight() != getHeight()) {
// First 2 steps are done in onMeasure but looks like we have to run again due to
// changed size.
mLayout.setExactMeasureSpecsFrom(this);
dispatchLayoutStep2();
} else {
// always make sure we sync them (to ensure mode is exact)
mLayout.setExactMeasureSpecsFrom(this);
}
dispatchLayoutStep3();
}
我看到这个方法的注释上有一句“handles animating changes caused by layout”,这不正是我要找的答案么!看到这个方法里最扎眼的就是这三个方法:
- dispatchLayoutStep1()
- dispatchLayoutStep2()
- dispatchLayoutStep3()
名字都起成这样了里面肯定是最核心的业务逻辑,所以我一个个点进去看,首先是step1,我只截取一些和我们的需求有关的代码段:
/**
* The first step of a layout where we;
* - process adapter updates
* - decide which animation should run
* - save information about current views
* - If necessary, run predictive layout and save its information
*/
private void dispatchLayoutStep1() {
···
if (mState.mRunSimpleAnimations) {
// Step 0: Find out where all non-removed items are, pre-layout
int count = mChildHelper.getChildCount();
for (int i = 0; i < count; ++i) {
final ViewHolder holder = getChildViewHolderInt(mChildHelper.getChildAt(i));
if (holder.shouldIgnore() || (holder.isInvalid() && !mAdapter.hasStableIds())) {
continue;
}
// 这一步是构建当前显示的每一个View位置记录。
// ItemHolderInfo就是存储Item位置信息的一个参数集。
// 注意这个是布局改变之前的位置参数。
final ItemHolderInfo animationInfo = mItemAnimator
.recordPreLayoutInformation(mState, holder,
ItemAnimator.buildAdapterChangeFlagsForAnimations(holder),
holder.getUnmodifiedPayloads());
//这个类用来存储所有需要做动画的Item信息,后面也会用到。
mViewInfoStore.addToPreLayout(holder, animationInfo);
if (mState.mTrackOldChangeHolders && holder.isUpdated() && !holder.isRemoved()
&& !holder.shouldIgnore() && !holder.isInvalid()) {
long key = getChangedHolderKey(holder);
// This is NOT the only place where a ViewHolder is added to old change holders
// list. There is another case where:
// * A VH is currently hidden but not deleted
// * The hidden item is changed in the adapter
// * Layout manager decides to layout the item in the pre-Layout pass (step1)
// When this case is detected, RV will un-hide that view and add to the old
// change holders list.
mViewInfoStore.addToOldChangeHolders(key, holder);
}
}
}
···
}
源码中在此方法里对当前各个Item的位置进行了存储,需要注意的是这时候没有调用LayoutManager#onLayoutChildren方法,也就是说这些信息都是新布局前的信息。
下面在看dispatchLayoutStep2()方法:
/**
* The second layout step where we do the actual layout of the views for the final state.
* This step might be run multiple times if necessary (e.g. measure).
*/
private void dispatchLayoutStep2() {
eatRequestLayout();
onEnterLayoutOrScroll();
mState.assertLayoutStep(State.STEP_LAYOUT | State.STEP_ANIMATIONS);
mAdapterHelper.consumeUpdatesInOnePass();
mState.mItemCount = mAdapter.getItemCount();
mState.mDeletedInvisibleItemCountSincePreviousLayout = 0;
// Step 2: Run layout
// 布局。
mState.mInPreLayout = false;
mLayout.onLayoutChildren(mRecycler, mState);
mState.mStructureChanged = false;
mPendingSavedState = null;
// onLayoutChildren may have caused client code to disable item animations; re-check
mState.mRunSimpleAnimations = mState.mRunSimpleAnimations && mItemAnimator != null;
mState.mLayoutStep = State.STEP_ANIMATIONS;
onExitLayoutOrScroll();
resumeRequestLayout(false);
}
这个方法很短,最重要的一步就是调用了LayoutManager#onLayoutChildren,也就是说这里已经对子View进行了重新的布局。看到这里我有一个疑问,此时新的布局已经完成了(虽然还没有绘制),也就是说如果我们设置了SpanCount从3到5,此时的布局已经是每行5个的布局了,那过渡动画还怎么做?带着这个疑问我们再来看dispatchLayoutStep3():
/**
* The final step of the layout where we save the information about views for animations,
* trigger animations and do any necessary cleanup.
*/
private void dispatchLayoutStep3() {
mState.assertLayoutStep(State.STEP_ANIMATIONS);
eatRequestLayout();
onEnterLayoutOrScroll();
mState.mLayoutStep = State.STEP_START;
if (mState.mRunSimpleAnimations) {
// Step 3: Find out where things are now, and process change animations.
// traverse list in reverse because we may call animateChange in the loop which may
// remove the target view holder.
for (int i = mChildHelper.getChildCount() - 1; i >= 0; i--) {
ViewHolder holder = getChildViewHolderInt(mChildHelper.getChildAt(i));
if (holder.shouldIgnore()) {
continue;
}
// 在这里找到布局之前存储的老布局item信息。
long key = getChangedHolderKey(holder);
final ItemHolderInfo animationInfo = mItemAnimator
.recordPostLayoutInformation(mState, holder);
ViewHolder oldChangeViewHolder = mViewInfoStore.getFromOldChangeHolders(key);
if (oldChangeViewHolder != null && !oldChangeViewHolder.shouldIgnore()) {
// run a change animation
// If an Item is CHANGED but the updated version is disappearing, it creates
// a conflicting case.
// Since a view that is marked as disappearing is likely to be going out of
// bounds, we run a change animation. Both views will be cleaned automatically
// once their animations finish.
// On the other hand, if it is the same view holder instance, we run a
// disappearing animation instead because we are not going to rebind the updated
// VH unless it is enforced by the layout manager.
final boolean oldDisappearing = mViewInfoStore.isDisappearing(
oldChangeViewHolder);
final boolean newDisappearing = mViewInfoStore.isDisappearing(holder);
if (oldDisappearing && oldChangeViewHolder == holder) {
// run disappear animation instead of change
mViewInfoStore.addToPostLayout(holder, animationInfo);
} else {
final ItemHolderInfo preInfo = mViewInfoStore.popFromPreLayout(
oldChangeViewHolder);
// we add and remove so that any post info is merged.
// 存储新item的位置。
mViewInfoStore.addToPostLayout(holder, animationInfo);
ItemHolderInfo postInfo = mViewInfoStore.popFromPostLayout(holder);
if (preInfo == null) {
handleMissingPreInfoForChangeError(key, holder, oldChangeViewHolder);
} else {
animateChange(oldChangeViewHolder, holder, preInfo, postInfo,
oldDisappearing, newDisappearing);
}
}
} else {
mViewInfoStore.addToPostLayout(holder, animationInfo);
}
}
// Step 4: Process view info lists and trigger animations
mViewInfoStore.process(mViewInfoProcessCallback);
}
···
}
源码中的注释已经很详细了,大概的事情就是:找到新布局和老布局中对应的item,在把有对应关系的新布局item位置信息存储到ViewInfoStore中,准备做切换动画。底下这段代码就是调用切换动画。
// Step 4: Process view info lists and trigger animations
mViewInfoStore.process(mViewInfoProcessCallback);
我们跟随他的调用堆栈,最终会惊奇的发现落在了我们熟悉的DefaultItemAnimator里:
@Override
public boolean animateMove(final ViewHolder holder, int fromX, int fromY,
int toX, int toY) {
final View view = holder.itemView;
fromX += (int) holder.itemView.getTranslationX();
fromY += (int) holder.itemView.getTranslationY();
resetAnimation(holder);
int deltaX = toX - fromX;
int deltaY = toY - fromY;
if (deltaX == 0 && deltaY == 0) {
dispatchMoveFinished(holder);
return false;
}
if (deltaX != 0) {
view.setTranslationX(-deltaX);
}
if (deltaY != 0) {
view.setTranslationY(-deltaY);
}
mPendingMoves.add(new MoveInfo(holder, fromX, fromY, toX, toY));
return true;
}
守得云开见月明,终于找到过渡动画的地方了!我们刚才提出的那个疑问也有了结果,已经布局完成了怎么过渡?设置Translate啊!
代码中大概的意思就是:根据holder布局前、布局后的位置,设置holder的translate,让holder重新布局在老布局的地方,并准备做translate逐渐变为0的动画。
添加Item大小变化的动画
原理弄清楚了,接下来就是简单了。
回到我们最初的问题,RecyclerView根据我们的调用方式,已经支持了setSpanCount变化的动画,唯一的问题是在做动画的时候item会直接变小而不是动画过渡。也就是说我们需要添加一个大小变化的动画。
我一开始想的是ItemAnimator应该也有支持item大小变化的Scale动画才对,但是找了一圈发现并没有。所以我们要自己手动添加,大概的实现方式就是给新布局中的Item设置一个Scale让它和老布局中的item一样大。
我们找到ItemAnimator中一个和RecyclerView对接的方法,ItemAnimator#animatePersistence(这个是item在位置改变时候会调用的方法),里面有item前后位置,包括大小的信息,我们重写这个方法,在此加入Scale变化的动画即可,代码如下:
public class AlbumItemAnimator extends DefaultItemAnimator {
private List<ScaleInfo> mPendingScaleInfos = new ArrayList<>();
private long mAnimationDelay = 0;
@Override
public boolean animateRemove(RecyclerView.ViewHolder holder) {
mAnimationDelay = getRemoveDuration();
return super.animateRemove(holder);
}
@Override
public boolean animatePersistence(@NonNull RecyclerView.ViewHolder viewHolder, @NonNull ItemHolderInfo preInfo,
@NonNull ItemHolderInfo postInfo) {
int preWidth = preInfo.right - preInfo.left;
int preHeight = preInfo.bottom - preInfo.top;
int postWidth = postInfo.right - postInfo.left;
int postHeight = postInfo.bottom - postInfo.top;
if (postWidth != 0 && postHeight != 0 && (preWidth != postWidth || preHeight != postHeight)) {
float xScale = preWidth / (float) postWidth;
float yScale = preHeight / (float) postHeight;
viewHolder.itemView.setPivotX(0);
viewHolder.itemView.setPivotY(0);
viewHolder.itemView.setScaleX(xScale);
viewHolder.itemView.setScaleY(yScale);
mPendingScaleInfos.add(new ScaleInfo(viewHolder, xScale, yScale, 1, 1));
}
return super.animatePersistence(viewHolder, preInfo, postInfo);
}
private void animateScaleImpl(ScaleInfo info) {
final View view = info.holder.itemView;
final ViewPropertyAnimator animation = view.animate();
animation.scaleX(info.toX);
animation.scaleY(info.toY);
animation.setDuration(getMoveDuration()).setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
mAnimationDelay = 0;
}
}).start();
}
@Override
public void runPendingAnimations() {
if (!mPendingScaleInfos.isEmpty()) {
Runnable scale = () -> {
for (ScaleInfo info : mPendingScaleInfos) {
animateScaleImpl(info);
}
mPendingScaleInfos.clear();
};
if (mAnimationDelay == 0) {
scale.run();
} else {
View view = mPendingScaleInfos.get(0).holder.itemView;
ViewCompat.postOnAnimationDelayed(view, scale, getRemoveDuration());
}
}
super.runPendingAnimations();
}
private class ScaleInfo {
public RecyclerView.ViewHolder holder;
public float fromX, fromY, toX, toY;
ScaleInfo(RecyclerView.ViewHolder holder, float fromX, float fromY, float toX, float toY) {
this.holder = holder;
this.fromX = fromX;
this.fromY = fromY;
this.toX = toX;
this.toY = toY;
}
}
}
里面还有一些小细节就不多少了,大概看看DefaultItemAnimator就可以明白了。最后实现的效果如下:
ezgif-5-76664ed061.gif
总结与感悟
- 感受最深的其实是RecyclerView的解藕,以前经常看一些文章说RecyclerView的LayoutManager与ItemAnimator等是完全解藕的,当时觉得不可思议,布局和动画是强相关的要怎么解藕?今天做了一遍代码才真正理解它的原理。
- 为什么ItemAnimator没有默认支持item的Scale动画,我想原因首先是ItemView可能是个复杂的View,设置Scale会导致前后绘制的图像不一致,我当前的这种方式只能是针对简单的一个图片Item才不会出错。如果使用不断的设置itemView的height和width来实现动画,性能上可能就会有问题(而且很有可能还有其他问题,你看Android官方的animator中从来的都没有支持View大小改变的动画)。
- 我们一开始调用的notifyItemChange其实不太标准,我们记得dispatchLayoutStep3中,做不做动画是根据mState.mRunSimpleAnimations这个标志位选择的,所以我们可以直接调用LayoutManager#requestSimpleAnimationsInNextLayout这个方法,会改变这个标志物的信息。
网友评论