GridLayoutManager切換SpanCount動畫------RecyclerView動畫實現解析

需求背景

最近主要在做相冊模塊的工作,接到一個需求是用戶可以切換相冊布局的排列方式,比如每行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

總結與感悟

  1. 感受最深的其實是RecyclerView的解藕,以前經常看一些文章說RecyclerView的LayoutManager與ItemAnimator等是完全解藕的,當時覺得不可思議,布局和動畫是強相關的要怎么解藕?今天做了一遍代碼才真正理解它的原理。
  2. 為什么ItemAnimator沒有默認支持item的Scale動畫,我想原因首先是ItemView可能是個復雜的View,設置Scale會導致前后繪制的圖像不一致,我當前的這種方式只能是針對簡單的一個圖片Item才不會出錯。如果使用不斷的設置itemView的height和width來實現動畫,性能上可能就會有問題(而且很有可能還有其他問題,你看Android官方的animator中從來的都沒有支持View大小改變的動畫)。
  3. 我們一開始調用的notifyItemChange其實不太標準,我們記得dispatchLayoutStep3中,做不做動畫是根據mState.mRunSimpleAnimations這個標志位選擇的,所以我們可以直接調用LayoutManager#requestSimpleAnimationsInNextLayout這個方法,會改變這個標志物的信息。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,527評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,687評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,640評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,957評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,682評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,011評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,009評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,183評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,714評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,435評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,665評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,148評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,838評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,251評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,588評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,379評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,627評論 2 380

推薦閱讀更多精彩內容