需求背景
最近主要在做相冊模塊的工作自赔,接到一個需求是用戶可以切換相冊布局的排列方式,比如每行3個或者是每行5個這種。因為我的相冊模塊是使用RecyclerView+GridLayoutManager做的,所以切換每行排列個數(shù)時需要調(diào)用GridLayoutManager.setSpanCount方法即可装畅。
但是如果光是這么做會發(fā)現(xiàn)它的變化很生硬,沒有中間的過度動畫沧烈,從產(chǎn)品層面來說掠兄,就有可能導(dǎo)致用戶瀏覽的視線丟失,所以我們需要添加一個過渡動畫來引導(dǎo)用戶,效果類似于Google Photo的切換排列效果蚂夕,效果如下:
剛接到這個需求的時候我是懵逼的迅诬,因為我對于RecyclerView動畫的理解,只停留在ItemAnimator的animateMove()婿牍、animateChange()的程度上百框,大概也就只能定義一個在item改變的動畫,但是這種動畫得怎么做半剐凇?
思考分析
在SpanCount改變的時候有這么多Item要變化柬泽,而且Item之間是要互相影響的慎菲,如果這種動畫要我們完全隔離RecyclerView來做一定是一個浩大的工程,而且容易出Bug,所以我一開始的方案就鎖定在要使用RecyclerView支持的方法來做。
我首先想到的是:RecyclerView在adapter調(diào)用notifyDataSetMove等方法的時候不是本事就會做動畫么特铝?如果我在切換SpanCount的時候隨便調(diào)用一下notifyItemChange會不會自動就把動畫做了壹瘟。我嘗試做了一下稻轨,效果如下:
哎喲政冻,這個動畫跟我們最終想要基本一致,沒想到一上手就解決了一半,我真是個天才!
但是我們可以看到逆屡,所有的Item在一開始做動畫的時候都變小了砍的,大概是因為設(shè)置了SpanCount床佳,重新計算了每個Item的大小導(dǎo)致了這種現(xiàn)象,我們想要的效果是在做動畫的同時縮小Item的大小,如果我們能做到這一點,那么整個需求就完成了。
我又仔細(xì)想了一會驮吱,看了一下ItemAnimator這個類中的一些方法拇砰,好像并沒有哪里支持Item變小動畫的丹莲,而且我心里又多了一個疑問:為什么setSpanCount可以配合notifyItemChange來做動畫甥材,動畫不應(yīng)該是調(diào)用notifyItemChange來控制的么,為什么數(shù)據(jù)源沒改變卻做了一個動畫性含?別看現(xiàn)在進(jìn)展很快洲赵,但是疑問越來越多,也越來越難解決商蕴。
源碼探索
現(xiàn)在的情況光靠我現(xiàn)有的知識是無法解決的叠萍,我首先想到的是上網(wǎng)搜有沒有類似的文章,搜了一圈發(fā)現(xiàn)果然沒有绪商,那只能自己去源碼中尋找答案了苛谷。
瞎找的過程就不贅述了,我最后把目標(biāo)鎖定在了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()
名字都起成這樣了里面肯定是最核心的業(yè)務(wù)邏輯瓢湃,所以我一個個點進(jìn)去看理张,首先是step1,我只截取一些和我們的需求有關(guān)的代碼段:
/**
* 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;
}
// 這一步是構(gòu)建當(dāng)前顯示的每一個View位置記錄绵患。
// ItemHolderInfo就是存儲Item位置信息的一個參數(shù)集雾叭。
// 注意這個是布局改變之前的位置參數(shù)。
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);
}
}
}
···
}
源碼中在此方法里對當(dāng)前各個Item的位置進(jìn)行了存儲,需要注意的是這時候沒有調(diào)用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);
}
這個方法很短管行,最重要的一步就是調(diào)用了LayoutManager#onLayoutChildren厨埋,也就是說這里已經(jīng)對子View進(jìn)行了重新的布局【枨辏看到這里我有一個疑問荡陷,此時新的布局已經(jīng)完成了(雖然還沒有繪制),也就是說如果我們設(shè)置了SpanCount從3到5迅涮,此時的布局已經(jīng)是每行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);
}
···
}
源碼中的注釋已經(jīng)很詳細(xì)了,大概的事情就是:找到新布局和老布局中對應(yīng)的item,在把有對應(yīng)關(guān)系的新布局item位置信息存儲到ViewInfoStore中渣蜗,準(zhǔn)備做切換動畫屠尊。底下這段代碼就是調(diào)用切換動畫。
// Step 4: Process view info lists and trigger animations
mViewInfoStore.process(mViewInfoProcessCallback);
我們跟隨他的調(diào)用堆棧耕拷,最終會驚奇的發(fā)現(xiàn)落在了我們熟悉的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;
}
守得云開見月明讼昆,終于找到過渡動畫的地方了!我們剛才提出的那個疑問也有了結(jié)果骚烧,已經(jīng)布局完成了怎么過渡浸赫?設(shè)置Translate啊赃绊!
代碼中大概的意思就是:根據(jù)holder布局前既峡、布局后的位置,設(shè)置holder的translate碧查,讓holder重新布局在老布局的地方运敢,并準(zhǔn)備做translate逐漸變?yōu)?的動畫。
添加Item大小變化的動畫
原理弄清楚了忠售,接下來就是簡單了传惠。
回到我們最初的問題,RecyclerView根據(jù)我們的調(diào)用方式稻扬,已經(jīng)支持了setSpanCount變化的動畫卦方,唯一的問題是在做動畫的時候item會直接變小而不是動畫過渡。也就是說我們需要添加一個大小變化的動畫泰佳。
我一開始想的是ItemAnimator應(yīng)該也有支持item大小變化的Scale動畫才對盼砍,但是找了一圈發(fā)現(xiàn)并沒有。所以我們要自己手動添加逝她,大概的實現(xiàn)方式就是給新布局中的Item設(shè)置一個Scale讓它和老布局中的item一樣大浇坐。
我們找到ItemAnimator中一個和RecyclerView對接的方法,ItemAnimator#animatePersistence(這個是item在位置改變時候會調(diào)用的方法)黔宛,里面有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;
}
}
}
里面還有一些小細(xì)節(jié)就不多少了,大概看看DefaultItemAnimator就可以明白了积仗。最后實現(xiàn)的效果如下:
總結(jié)與感悟
- 感受最深的其實是RecyclerView的解藕疆拘,以前經(jīng)常看一些文章說RecyclerView的LayoutManager與ItemAnimator等是完全解藕的寂曹,當(dāng)時覺得不可思議哎迄,布局和動畫是強相關(guān)的要怎么解藕回右?今天做了一遍代碼才真正理解它的原理。
- 為什么ItemAnimator沒有默認(rèn)支持item的Scale動畫漱挚,我想原因首先是ItemView可能是個復(fù)雜的View翔烁,設(shè)置Scale會導(dǎo)致前后繪制的圖像不一致,我當(dāng)前的這種方式只能是針對簡單的一個圖片Item才不會出錯旨涝。如果使用不斷的設(shè)置itemView的height和width來實現(xiàn)動畫蹬屹,性能上可能就會有問題(而且很有可能還有其他問題,你看Android官方的animator中從來的都沒有支持View大小改變的動畫)白华。
- 我們一開始調(diào)用的notifyItemChange其實不太標(biāo)準(zhǔn)慨默,我們記得dispatchLayoutStep3中,做不做動畫是根據(jù)mState.mRunSimpleAnimations這個標(biāo)志位選擇的弧腥,所以我們可以直接調(diào)用LayoutManager#requestSimpleAnimationsInNextLayout這個方法厦取,會改變這個標(biāo)志物的信息。