GridLayoutManager切換SpanCount動畫------RecyclerView動畫實現(xiàn)解析

需求背景

最近主要在做相冊模塊的工作自赔,接到一個需求是用戶可以切換相冊布局的排列方式,比如每行3個或者是每行5個這種。因為我的相冊模塊是使用RecyclerView+GridLayoutManager做的,所以切換每行排列個數(shù)時需要調(diào)用GridLayoutManager.setSpanCount方法即可装畅。

但是如果光是這么做會發(fā)現(xiàn)它的變化很生硬,沒有中間的過度動畫沧烈,從產(chǎn)品層面來說掠兄,就有可能導(dǎo)致用戶瀏覽的視線丟失,所以我們需要添加一個過渡動畫來引導(dǎo)用戶,效果類似于Google Photo的切換排列效果蚂夕,效果如下:


ezgif-5-7ab69fc25d.gif

剛接到這個需求的時候我是懵逼的迅诬,因為我對于RecyclerView動畫的理解,只停留在ItemAnimator的animateMove()婿牍、animateChange()的程度上百框,大概也就只能定義一個在item改變的動畫,但是這種動畫得怎么做半剐凇?

思考分析

在SpanCount改變的時候有這么多Item要變化柬泽,而且Item之間是要互相影響的慎菲,如果這種動畫要我們完全隔離RecyclerView來做一定是一個浩大的工程,而且容易出Bug,所以我一開始的方案就鎖定在要使用RecyclerView支持的方法來做。

我首先想到的是:RecyclerView在adapter調(diào)用notifyDataSetMove等方法的時候不是本事就會做動畫么特铝?如果我在切換SpanCount的時候隨便調(diào)用一下notifyItemChange會不會自動就把動畫做了壹瘟。我嘗試做了一下稻轨,效果如下:

ezgif-5-d00dc6d23a.gif

哎喲政冻,這個動畫跟我們最終想要基本一致,沒想到一上手就解決了一半,我真是個天才!

但是我們可以看到逆屡,所有的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)的效果如下:


ezgif-5-76664ed061.gif

總結(jié)與感悟

  1. 感受最深的其實是RecyclerView的解藕疆拘,以前經(jīng)常看一些文章說RecyclerView的LayoutManager與ItemAnimator等是完全解藕的寂曹,當(dāng)時覺得不可思議哎迄,布局和動畫是強相關(guān)的要怎么解藕回右?今天做了一遍代碼才真正理解它的原理。
  2. 為什么ItemAnimator沒有默認(rèn)支持item的Scale動畫漱挚,我想原因首先是ItemView可能是個復(fù)雜的View翔烁,設(shè)置Scale會導(dǎo)致前后繪制的圖像不一致,我當(dāng)前的這種方式只能是針對簡單的一個圖片Item才不會出錯旨涝。如果使用不斷的設(shè)置itemView的height和width來實現(xiàn)動畫蹬屹,性能上可能就會有問題(而且很有可能還有其他問題,你看Android官方的animator中從來的都沒有支持View大小改變的動畫)白华。
  3. 我們一開始調(diào)用的notifyItemChange其實不太標(biāo)準(zhǔn)慨默,我們記得dispatchLayoutStep3中,做不做動畫是根據(jù)mState.mRunSimpleAnimations這個標(biāo)志位選擇的弧腥,所以我們可以直接調(diào)用LayoutManager#requestSimpleAnimationsInNextLayout這個方法厦取,會改變這個標(biāo)志物的信息。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末管搪,一起剝皮案震驚了整個濱河市虾攻,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌更鲁,老刑警劉巖霎箍,帶你破解...
    沈念sama閱讀 211,265評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異岁经,居然都是意外死亡朋沮,警方通過查閱死者的電腦和手機蛇券,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,078評論 2 385
  • 文/潘曉璐 我一進(jìn)店門缀壤,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人纠亚,你說我怎么就攤上這事塘慕。” “怎么了蒂胞?”我有些...
    開封第一講書人閱讀 156,852評論 0 347
  • 文/不壞的土叔 我叫張陵图呢,是天一觀的道長。 經(jīng)常有香客問我骗随,道長蛤织,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,408評論 1 283
  • 正文 為了忘掉前任鸿染,我火速辦了婚禮指蚜,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘涨椒。我一直安慰自己摊鸡,他們只是感情好绽媒,可當(dāng)我...
    茶點故事閱讀 65,445評論 5 384
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著免猾,像睡著了一般是辕。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上猎提,一...
    開封第一講書人閱讀 49,772評論 1 290
  • 那天获三,我揣著相機與錄音,去河邊找鬼忧侧。 笑死石窑,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的蚓炬。 我是一名探鬼主播松逊,決...
    沈念sama閱讀 38,921評論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼肯夏!你這毒婦竟也來了经宏?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,688評論 0 266
  • 序言:老撾萬榮一對情侶失蹤驯击,失蹤者是張志新(化名)和其女友劉穎烁兰,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體徊都,經(jīng)...
    沈念sama閱讀 44,130評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡沪斟,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,467評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了暇矫。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片主之。...
    茶點故事閱讀 38,617評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖李根,靈堂內(nèi)的尸體忽然破棺而出槽奕,到底是詐尸還是另有隱情,我是刑警寧澤房轿,帶...
    沈念sama閱讀 34,276評論 4 329
  • 正文 年R本政府宣布粤攒,位于F島的核電站,受9級特大地震影響囱持,放射性物質(zhì)發(fā)生泄漏夯接。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,882評論 3 312
  • 文/蒙蒙 一纷妆、第九天 我趴在偏房一處隱蔽的房頂上張望盔几。 院中可真熱鬧,春花似錦凭需、人聲如沸问欠。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,740評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽顺献。三九已至旗国,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間注整,已是汗流浹背能曾。 一陣腳步聲響...
    開封第一講書人閱讀 31,967評論 1 265
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留肿轨,地道東北人寿冕。 一個月前我還...
    沈念sama閱讀 46,315評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像椒袍,于是被迫代替她去往敵國和親驼唱。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,486評論 2 348

推薦閱讀更多精彩內(nèi)容

  • ??距離上一篇RecyclerView源碼分析的文章已經(jīng)過去了10多天驹暑,今天我們將來看看RecyclerView的...
    瓊珶和予閱讀 3,292評論 2 14
  • 這篇文章分三個部分玫恳,簡單跟大家講一下 RecyclerView 的常用方法與奇葩用法;工作原理與ListView比...
    LucasAdam閱讀 4,379評論 0 27
  • 14年Google發(fā)布了萬眾期待的Android 5.0 优俘。隨之而來的還有新的設(shè)計方案 Material Desi...
    Choices閱讀 4,674評論 1 23
  • 湖上玫瑰/嬌紅似昔/勿忘我草/卻已忘儂/惆悵恐重來無日京办。 支離病骨/幾度秋風(fēng)/浮生若夢/無一非空/樓臺轉(zhuǎn)眼成虛...
    靴子娃娃閱讀 285評論 1 2
  • 江小魚是個地地道道的農(nóng)村姑娘,出生在一個普普通通的農(nóng)民家庭帆焕,父親是這個家里的第七個孩子惭婿,祖父和祖母養(yǎng)育了五...
    秾華如夢ing閱讀 387評論 0 4