美文网首页Android开发探索
GridLayoutManager切换SpanCount动画--

GridLayoutManager切换SpanCount动画--

作者: 快乐肥宅罗 | 来源:发表于2018-07-01 19:10 被阅读195次

    需求背景

    最近主要在做相册模块的工作,接到一个需求是用户可以切换相册布局的排列方式,比如每行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这个方法,会改变这个标志物的信息。

    相关文章

      网友评论

        本文标题:GridLayoutManager切换SpanCount动画--

        本文链接:https://www.haomeiwen.com/subject/wzecuftx.html