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阅读 214,588评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,456评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,146评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,387评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,481评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,510评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,522评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,296评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,745评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,039评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,202评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,901评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,538评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,165评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,415评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,081评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,085评论 2 352

推荐阅读更多精彩内容