理解RecyclerView(五)—RecyclerView的绘制流程

前言:做人如果没有梦想,那和咸鱼有什么区别。        ——《少林足球》

一、概述

  上一篇文章对RecyclerView中实现了如何高度自定义点击事件、万能ViewHolder、万能适配器的封装和使用。最开始就提到,RecyclerView支持各种各样的布局效果,其核心关键在于RecyclerView.LayoutManager中,使用时我们是需要setLayoutManager()设置布局管理器的。RecyclerView已经将一部分功能抽离出来,在布局管理器中另外处理,也方便开发者自行拓展。LayoutManager就是负责RecyclerView的测量和布局以及itemView的回收和复用。今天这里主要结合LinearLayoutManager来分析RecyclerView的绘制流程。

RecyclerView提供了三中布局管理器:

  • LinearLayoutManager      以列表的方式展示item,有水平方向RecyclerView.HORIZONTAL和垂直方向RecyclerView.VERTICAL;
  • GridLayoutManager       以网格的方式展示item,有水平方向和垂直方向;
  • StaggeredGridLayoutManager   以瀑布流的方式展示item,有水平方向和垂直方向。

这里就不一一分析了,前面的文章已经做了详细的介绍,不了解的同学可以回头看一下。这里以LinearLayoutManager为例来分析RecyclerView的绘制流程。

温馨提示:本文源码基于androidx.recyclerview:recyclerview:1.2.0-alpha01

二、RecyclerView的绘制三个步骤

  RecyclerView设置布局管理器,这一步是必要的,用什么样的LayoutManager来绘制RecyclerView,不然RecyclerView也不知道怎么绘制。

 recyclerView.setLayoutManager(manager);

从设置布局管理器方法入手,setLayoutManager()设置布局管理器给RecyclerView使用:

   public void setLayoutManager(@Nullable LayoutManager layout) {
        if (layout == mLayout) {//和之前的管理器一样则直接return
            return;
        }
        stopScroll();//停止滚动
        if (mLayout != null) {//每次设置layoutManager都重新设置recyclerView的初始参数,动画回收view等
        	if (mItemAnimator != null) {
                mItemAnimator.endAnimations();//结束动画
            }
            mLayout.removeAndRecycleAllViews(mRecycler);//移除回收所有itemView
            mLayout.removeAndRecycleScrapInt(mRecycler);//移除回收所有已经废弃的itemView
            mRecycler.clear();//清除所有缓存
            
            mLayout.setRecyclerView(null);//重置RecyclerView
            mLayout = null;
        } else {
            mRecycler.clear();
        }
     	·······
     	mLayout.setRecyclerView(this);//LayoutManager与RecyclerView关联
        mRecycler.updateViewCacheSize();//更新缓存大小
        requestLayout();//请求重绘
    }

这里首先做了重置回收工作,然后LayoutManager与RecyclerView关联起来,最后请求重绘。这里调用了请求重绘requestLayout()方法,那么说明每次设置layoutManager都会执行View树的绘制,那么就会重走RecyclerView的onMeasure()onLayout()onDraw()绘制三部曲。

    public void requestLayout() {
        if (mRecyclerView != null) {
            mRecyclerView.requestLayout();//请求重绘
        }
    }

2.1 onMeasure()

我们来看看RecyclerView的onMeasure()方法:

    @Override
    protected void onMeasure(int widthSpec, int heightSpec) {
        if (mLayout == null) {//如果mLayout为空则采用默认测量,然后结束
            defaultOnMeasure(widthSpec, heightSpec);
            return;
        }
        if (mLayout.mAutoMeasure) {//如果为自动测量,默认为true
        	final int widthMode = MeasureSpec.getMode(widthSpec);
            final int heightMode = MeasureSpec.getMode(heightSpec);
        	mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);//测量RecyclerView的宽高
        	 //当前RecyclerView的宽高是否为精确值
        	final boolean measureSpecModeIsExactly =
                    widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY;
            if (measureSpecModeIsExactly || mAdapter == null) {//如果RecyclerView的宽高为精确值或者mAdapter为空,则结束
                return;
            }
            //RecyclerView的宽高为wrap_content时,即measureSpecModeIsExactly = false则进行测量
            //因为RecyclerView的宽高为wrap_content时,需要先测量itemView的宽高才能知道RecyclerView的宽高
            if (mState.mLayoutStep == State.STEP_START) {//还没测量过
                dispatchLayoutStep1();//1.适配器更新、动画运行、保存当前视图的信息、运行预测布局
            }
            dispatchLayoutStep2();//2.最终实际的布局视图,如果有必要会多次运行
            //根据itemView得到RecyclerView的宽高
            mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec);
        }
    }

onMeasure()主要是RecyclerView宽高测量工作,主要有两种情况:

  • (1)当RecyclerView的宽高为match_parent或者精确值时,即measureSpecModeIsExactly = true,此时只需要测量自身的宽高就知道RecyclerView的宽高,测量方法结束;
  • (2)当RecyclerView的宽高为wrap_content时,即measureSpecModeIsExactly = false,会往下执行dispatchLayoutStep1()dispatchLayoutStep2(),就是遍历测量ItemView的大小从而确定RecyclerView的宽高,这种情况真正的测量操作都是在dispatchLayoutStep2()中完成。

dispatchLayoutStep1()dispatchLayoutStep2()下面会讲解到。

2.2 onLayout()

onLayout()方法中, 直接调用dispatchLayout()方法布局:

   @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        TraceCompat.beginSection(TRACE_ON_LAYOUT_TAG);
        dispatchLayout(); //直接调用dispatchLayout()方法布局
        TraceCompat.endSection();
        mFirstLayoutComplete = true;
    }

dispatchLayout()layoutChildren()的包装器,它处理由布局引起的动态变化:

  void dispatchLayout() {
  		······
        mState.mIsMeasuring = false;//设置RecyclerView布局完成状态,前面已经设置预布局完成了。
        if (mState.mLayoutStep == State.STEP_START) {//如果没在OnMeasure阶段提前测量子ItemView
            dispatchLayoutStep1();//布局第一步:适配器更新、动画运行、保存当前视图的信息、运行预测布局
            mLayout.setExactMeasureSpecsFrom(this);
            dispatchLayoutStep2();
        } else if (mAdapterHelper.hasUpdates() || mLayout.getWidth() != getWidth()
                || mLayout.getHeight() != getHeight()) {//前两步完成测量,但是因为大小改变不得不再次运行下面的代码
            mLayout.setExactMeasureSpecsFrom(this);
            dispatchLayoutStep2();//布局第二步:最终实际的布局视图,如果有必要会多次运行
        } else {
            mLayout.setExactMeasureSpecsFrom(this);
        }
        dispatchLayoutStep3();//布局第三步:最后一步的布局,保存视图动画、触发动画和不必要的清理。
    }

可以看到dispatchLayout()onMeasure()阶段中一样选择性地进行测量布局的三个步骤:

  • 1、如果没在onMeasure阶段提前测量子ItemView,即RecyclerView宽高为match_parent或者精确值时,调用dispatchLayoutStep1()dispatchLayoutStep2()测量itemView宽高;
  • 2、如果在onMeasure阶段提前测量子ItemView,但是子视图发生了改变或者期望宽高和实际宽高不一致,则会调用dispatchLayoutStep2()重新测量;
  • 3、最后都会执行dispatchLayoutStep3()方法。

(1)我们来看看dispatchLayoutStep1、2、3分发布局的三个步骤:dispatchLayoutStep1()主要是进行预布局,适配器更新、动画运行、保存当前视图的信息等工作;

  private void dispatchLayoutStep1() {
        mState.assertLayoutStep(State.STEP_START);
        fillRemainingScrollValues(mState);
        mState.mIsMeasuring = false;
        startInterceptRequestLayout();//拦截布局请求
        mViewInfoStore.clear();//itemView信息清除
        onEnterLayoutOrScroll();
        //测量和分派布局时,更新适配器和计算那种类型要运行的动画
        processAdapterUpdatesAndSetAnimationFlags();
        saveFocusInfo();//保存焦点信息
        mState.mTrackOldChangeHolders = mState.mRunSimpleAnimations && mItemsChanged;
        mItemsAddedOrRemoved = mItemsChanged = false;
        mState.mInPreLayout = mState.mRunPredictiveAnimations;
        mState.mItemCount = mAdapter.getItemCount();
        findMinMaxChildLayoutPositions(mMinMaxLayoutPositions);//找到可绘制itemView最小最大position

        if (mState.mRunSimpleAnimations) {
            //获得界面上可以显示的个数
            int count = mChildHelper.getChildCount();
            for (int i = 0; i < count; ++i) {
                final ViewHolder holder = getChildViewHolderInt(mChildHelper.getChildAt(i));
                //动画信息
                final ItemHolderInfo animationInfo = mItemAnimator
                        .recordPreLayoutInformation(mState, holder,
                                ItemAnimator.buildAdapterChangeFlagsForAnimations(holder),
                                holder.getUnmodifiedPayloads());
                //保存holder和动画信息到预布局中
                mViewInfoStore.addToPreLayout(holder, animationInfo);
            }
        }
        //运行与布局,将会使用旧的item的position,布局管理器布局所有
        if (mState.mRunPredictiveAnimations) {
            //保存旧的管理器可以运行的逻辑
            saveOldPositions();
            final boolean didStructureChange = mState.mStructureChanged;
            mState.mStructureChanged = false;
            //布局itemView
            mLayout.onLayoutChildren(mRecycler, mState);
            mState.mStructureChanged = didStructureChange;
        }
        stopInterceptRequestLayout(false);//回复绘制锁定
        mState.mLayoutStep = State.STEP_LAYOUT;
    }

(2)dispatchLayoutStep2()表示对最终状态的视图进行实际布局:

  private void dispatchLayoutStep2() {
        startInterceptRequestLayout();//拦截请求布局
        onEnterLayoutOrScroll();
        //设置布局状态和动画状态
        mState.assertLayoutStep(State.STEP_LAYOUT | State.STEP_ANIMATIONS);
        mAdapterHelper.consumeUpdatesInOnePass();
        mState.mItemCount = mAdapter.getItemCount();
        mState.mDeletedInvisibleItemCountSincePreviousLayout = 0;

        //预布局完成,开始布局itemView
        mState.mInPreLayout = false;
        mLayout.onLayoutChildren(mRecycler, mState);
		······
        stopInterceptRequestLayout(false);//停止拦截布局请求
    }

(3)dispatchLayoutStep3()是布局的最后一步,保存view的动画信息,执行动画,和一些必要的清理工作:

   private void dispatchLayoutStep3() {
        mState.assertLayoutStep(State.STEP_ANIMATIONS);
        startInterceptRequestLayout();//开始拦截布局请求

        mState.mLayoutStep = State.STEP_START;//布局开始状态
        if (mState.mRunSimpleAnimations) {
            //步骤3:找出事情现在的位置,并处理更改动画。
            //反向遍历列表,因为我们可能会在循环中调用animateChange,这可能会删除目标视图持有者。
            for (int i = mChildHelper.getChildCount() - 1; i >= 0; i--) {
                ViewHolder holder = getChildViewHolderInt(mChildHelper.getChildAt(i));
                final ItemHolderInfo animationInfo = mItemAnimator.recordPostLayoutInformation(mState, holder);
                ViewHolder oldChangeViewHolder = mViewInfoStore.getFromOldChangeHolders(key);
                //运行一个变更动画。如果一个项目被更改,但是更新后的版本正在消失,则会产生冲突的情况。
                //由于标记为正在消失的视图可能会超出界限,所以我们运行一个change动画。两个视图都将在动画完成后自动清除。
                //另一方面,如果是相同的视图持有者实例,我们将运行一个正在消失的动画,因为我们不会重新绑定更新的VH,除非它是由布局管理器强制执行的。

                //运行消失动画而不是改变
                mViewInfoStore.addToPostLayout(holder, animationInfo);
                final ItemHolderInfo preInfo = mViewInfoStore.popFromPreLayout(oldChangeViewHolder);
                //我们添加和删除,这样任何的布置信息都是合并的
                mViewInfoStore.addToPostLayout(holder, animationInfo);

                ItemHolderInfo postInfo = mViewInfoStore.popFromPostLayout(holder);
                mViewInfoStore.addToPostLayout(holder, animationInfo);
            }

            //处理视图信息列表和触发动画
            mViewInfoStore.process(mViewInfoProcessCallback);
        }
        //回收废弃的视图
        mLayout.removeAndRecycleScrapInt(mRecycler);
        //重置状态
        mState.mPreviousLayoutItemCount = mState.mItemCount;
        mDataSetHasChangedAfterLayout = false;

        //清除mChangedScrap中的数据
        mRecycler.mChangedScrap.clear();
        mRecycler.updateViewCacheSize();//更新缓存大小

        mLayout.onLayoutCompleted(mState);//布局完成状态
        onExitLayoutOrScroll();
        stopInterceptRequestLayout(false);//停止拦截布局请求
        mViewInfoStore.clear();//itemView信息清除

        recoverFocusFromState();//回复焦点
        resetFocusInfo();//重置焦点信息
    }

总结一下这分发布局的三个步骤:

  • dispatchLayoutStep1()  表示进行预布局,适配器更新、动画运行、保存当前视图的信息等工作;
  • dispatchLayoutStep2()  表示对最终状态的视图进行实际布局,有必要时会多次执行;
  • dispatchLayoutStep3()  表示布局最后一步,保存和触发有关动画的信息,相关清理等工作。

2.3 onDraw()

来到最后一步的绘制onDraw()方法中,如果不需要一些特殊的效果,在TextView、ImageView控件中已经绘制完了。

    @Override
    public void onDraw(Canvas c) {
        super.onDraw(c);//所有itemView先绘制
		//分别绘制ItemDecoration
        final int count = mItemDecorations.size();
        for (int i = 0; i < count; i++) {
            mItemDecorations.get(i).onDraw(c, this, mState);
        }
    }

2.4 RecyclerView的绘制三个步骤总结:

1、RecyclerView的itemView可能会被测量多次,如果RecyclerView的宽高是固定值或者match_parent,那么在onMeasure()阶段是不会提前测量ItemView布局,如果RecyclerView的宽高是wrap_content,由于还没有知道RecyclerView的实际宽高,那么会提前在onMeasure()阶段遍历测量itemView布局确定内容显示区域的宽高值来确定RecyclerView的实际宽高;

2、dispatchLayoutStep1()dispatchLayoutStep2()dispatchLayoutStep3()这三个方法一定会执行,在RecyclerView的实际宽高不确定时,会提前多次执行dispatchLayoutStep1()dispatchLayoutStep2()方法,最后在onLayout()阶段执行 dispatchLayoutStep3(),如果有itemView发生改变会再次执行dispatchLayoutStep2()

3、正在的测量和布局itemView实际在dispatchLayoutStep2()方法中。

RecyclerView的绘制三个步骤流程图:
在这里插入图片描述

三、LinearLayoutManager填充、测量、布局过程

  RecyclerView的绘制经过measure、layout、draw三个步骤,但是itemView的真正布局时委托给各个的LayoutManager中处理,上面LinearLayoutManager可以知道dispatchLayoutStep2()是实际布局视图步骤,通过LayoutManager调用onLayoutChildren()方法进行布局itemView,它是绘制itemView的核心方法,表示从给定的适配器中列出所有相关的子视图。

3.1 onLayoutChildren()布局itemView

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        // 1) 检查子类和其他变量找到描点坐标和描点位置
        // 2) 从开始填补,从底部堆积
        // 3) 从底部填补,从顶部堆积
        // 4) 从底部堆积来满足需求
        // 创建布局状态
        if (mPendingSavedState != null || mPendingScrollPosition != RecyclerView.NO_POSITION) {
            if (state.getItemCount() == 0) {
                removeAndRecycleAllViews(recycler);//移除所有子View
                return;
            }
        }
        ensureLayoutState();
        mLayoutState.mRecycle = false;//禁止回收
        //颠倒绘制布局
        resolveShouldLayoutReverse();

        final View focused = getFocusedChild();//获取目前持有焦点的child
        if (!mAnchorInfo.mValid || mPendingScrollPosition != RecyclerView.NO_POSITION
                || mPendingSavedState != null) {
            mAnchorInfo.reset();//重置锚点信息
            mAnchorInfo.mLayoutFromEnd = mShouldReverseLayout ^ mStackFromEnd;
            //1. 计算更新描点位置和坐标
            updateAnchorInfoForLayout(recycler, state, mAnchorInfo);
            mAnchorInfo.mValid = true;
        }
		·······
        //计算第一布局的方向
        int startOffset;
        int endOffset;
        final int firstLayoutDirection;

        onAnchorReady(recycler, state, mAnchorInfo, firstLayoutDirection);
        detachAndScrapAttachedViews(recycler);//暂时分离已经附加的view,即将所有child detach并通过Scrap回收
        mLayoutState.mInfinite = resolveIsInfinite();
        mLayoutState.mIsPreLayout = state.isPreLayout();
      
        mLayoutState.mNoRecycleSpace = 0;
        //2.开始填充,从底部开始堆叠;
        if (mAnchorInfo.mLayoutFromEnd) {
            //描点位置从start位置开始填充ItemView布局
            updateLayoutStateToFillStart(mAnchorInfo);
            fill(recycler, mLayoutState, state, false);//填充所有itemView
           
            //描点位置从end位置开始填充ItemView布局
            updateLayoutStateToFillEnd(mAnchorInfo);
            fill(recycler, mLayoutState, state, false);//填充所有itemView
            endOffset = mLayoutState.mOffset;
        }else { //3.向底填充,从上往下堆放;
            //描点位置从end位置开始填充ItemView布局
            updateLayoutStateToFillEnd(mAnchorInfo);
            fill(recycler, mLayoutState, state, false);
 
            //描点位置从start位置开始填充ItemView布局
            updateLayoutStateToFillStart(mAnchorInfo);
            fill(recycler, mLayoutState, state, false);
            startOffset = mLayoutState.mOffset;
        }
        //4.计算滚动偏移量,如果有必要会在调用fill方法去填充新的ItemView
         layoutForPredictiveAnimations(recycler, state, startOffset, endOffset);
    }

首先是状态判断和一些准备工作,对描点信息选择和更新, detachAndScrapAttachedViews(recycler)暂时将已经附加的view分离,缓存Scrap中,下次重新填充时直接拿出来复用。然后计算是从哪个方向开始布局。布局算法如下:

  • 1.通过检查子元素和其他变量,找到一个锚点坐标和一个锚点项的位置;
  • 2.开始填充,从底部开始堆叠;
  • 3.向底填充,从上往下堆放;
  • 4.滚动以满足要求,如堆栈从底部。

3.2 fill()开始填充itemView

填充布局交给了fill()方法,表示填充由layoutState定义的给定布局。为什么要fill两次呢,我们来看看fill()方法:

  //填充方法,返回的是填充itemView的像素,方便后续滚动时使用
  int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
            RecyclerView.State state, boolean stopOnFocusable) {
       recycleByLayoutState(recycler, layoutState);//回收滑出屏幕的view
        int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
        LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
        //核心  == while()循环 ==
        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {//一直循环,知道没有数据
            layoutChunkResult.resetInternal();
            //填充itemView的核心方法
            layoutChunk(recycler, state, layoutState, layoutChunkResult);
            ······
            if (layoutChunkResult.mFinished) {//布局结束,退出循环
                break;
            }
            layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;//根据添加的child高度偏移计算   
        }
     	······
        return start - layoutState.mAvailable;//返回这次填充的区域大小
    }

fill()核心就是一个while()循环,循环执行layoutChunk()填充一个itemView到屏幕,同时返回这次填充的区域大小。首先根据屏幕还有多少剩余空间remainingSpace,根据这个数值减去子View所占的空间大小,小于0时布局子View结束,如果当前所有子View还没有超过remainingSpace时,调用layoutChunk()安排View的位置。

3.3 layoutChunk()对itemView创建、填充、测量、布局

layoutChunk()作为最终填充布局itemView的方法,对itemView创建、填充、测量、布局,主要有以下几个步骤:

  • 1.layoutState.next(recycler)从缓存中获取itemView,如果没有则创建itemView;
  • 2.根据实际情况来添加itemView到RecyclerView中,最终调用的还是ViewGroup的addView()方法;
  • 3.measureChildWithMargins()测量itemView大小包括父视图的填充、项目装饰和子视图的边距;
  • 4.根据计算好的left, top, right, bottom通过layoutDecoratedWithMargins()使用坐标在RecyclerView中布局给定的itemView。
   void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
            LayoutState layoutState, LayoutChunkResult result) {
        //1.从缓存中获取或者创建itemView
        View view = layoutState.next(recycler);//获取当前postion需要展示的View
       	······
       	//2.根据实际情况来添加itemView到RecyclerView中,最终调用的还是ViewGroup的addView()方法
        if (layoutState.mScrapList == null) {
            if (mShouldReverseLayout == (layoutState.mLayoutDirection
                    == LayoutState.LAYOUT_START)) {
                addView(view);
            } else {
                addView(view, 0);
            }
        } 
        
        //3.测量子View大小包括父视图的填充、项目装饰和子视图的边距
        measureChildWithMargins(view, 0, 0);
        result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
        //计算一个ItemView的left, top, right, bottom坐标值
        int left, top, right, bottom;
       	······
        //4.使用坐标在RecyclerView中布局给定的itemView
        //计算正确的布局位置,减去margin,计算所有视图的边界框(包括margin和装饰)
        layoutDecoratedWithMargins(view, left, top, right, bottom);//调用child.layout进行布局
    }

通过layoutState.next()从缓存中获取itemView如果没有就创建一个新的itemView,然后addView()根据实际情况来添加itemView到RecyclerView中,最终调用的还是ViewGroupaddView()方法,接着通过 measureChildWithMargins()测量子View大小包括父视图的填充、项目装饰和子视图的边距;最后getDecoratedMeasuredWidth()通过计算好的left, top, right, bottom值在RecyclerView坐标中布局给定的itemView,注意这里的宽度是item+decoration的总宽度。

   View next(RecyclerView.Recycler recycler) {
            if (mScrapList != null) {
                return nextViewFromScrapList();
            }
            final View view = recycler.getViewForPosition(mCurrentPosition);
            mCurrentPosition += mItemDirection;
            return view;
        }

获取itemView,并且如果mScrapList中有缓存的View 则使用缓存的view,如果没有mScrapList 就创建view,并添加到mScrapList 中。接下来getViewForPosition()方法主要是RecyclerView的缓存机制,后续的文章会讲解到。

3.4 LinearLayoutManager填充、测量、布局过程总结:

onLayoutChildren()表示从给定的适配器中列出所有相关的子视图,填充布局交给了fill()方法,填充由layoutState定义的给定布局,while()循环执行layoutChunk()填充一个itemView到屏幕,作为最终填充布局itemView的方法,layoutState.next(recycler)从缓存中获取或者创建itemView,通过addView()添加itemView到RecyclerView中,其实最终调用的还是ViewGroup的addView()方法,measureChildWithMargins()测量itemView大小包括父视图的填充、项目装饰和子视图的边距,最后layoutDecoratedWithMargins()根据计算好的left, top, right, bottom通过使用坐标在RecyclerView中布局给定的itemView。

流程图如下:
在这里插入图片描述

点关注,不迷路


好了各位,以上就是这篇文章的全部内容了,能看到这里的人呀,都是人才

我是suming,感谢各位的支持和认可,您的点赞、评论、收藏【一键三连】就是我创作的最大动力,我们下篇文章见!

如果本篇博客有任何错误,请批评指教,不胜感激 !

要想成为一个优秀的安卓开发者,这里有必须要掌握的知识架构,一步一步朝着自己的梦想前进!Keep Moving!


相关文章:

理解RecyclerView(五)

 ● RecyclerView的绘制流程

理解RecyclerView(六)

 ● RecyclerView的滑动原理

理解RecyclerView(七)

 ● RecyclerView的嵌套滑动机制

理解RecyclerView(八)

 ● RecyclerView的回收复用缓存机制详解

理解RecyclerView(九)

 ● RecyclerView的自定义LayoutManager

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值