RecyclerView 滑动布局源码分析:带你深入掌握列表滑动机制

这篇具有很好参考价值的文章主要介绍了RecyclerView 滑动布局源码分析:带你深入掌握列表滑动机制。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

作者:maxcion

现在RV已经初始化好了,那当我们进行滑动交互时代码又是如何执行的呢?

RV优秀就优秀在他是动态布局的,与ScrollView不同在于:ScrollView是初始化时将所有child都inflateaddRV是只inflate屏幕展示得下的child.

如果我们有100个child:

  • ScrollView便会在初始化时就inflateadd100个child,这样滑动的时候ScrollView不用执行过多的逻辑.
  • RV由于只会inflate部分child,所以当滑动的时候就会涉及动态inflateaddremove等逻辑.

RV再强大,他终究还是View,那他也逃离不了android触摸事件传递的限制,所以如果RV想要响应滑动事件,那一切的开始必然在onTouchEvent()中.

一切的开始

RV.onTouchEvent()

public boolean onTouchEvent(MotionEvent e) {
    if (mLayoutSuppressed || mIgnoreMotionEventTillDown) {
        return false;
    }
    if (dispatchToOnItemTouchListeners(e)) {
        cancelScroll();
        return true;
    }

    if (mLayout == null) {
        return false;
    }

    //判断滑动方向,这里我们选择以垂直方向滑动为例
    final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
    final boolean canScrollVertically = mLayout.canScrollVertically();
    //这个就是创建一个 速度跟踪器
    if (mVelocityTracker == null) {
        mVelocityTracker = VelocityTracker.obtain();
    }
    boolean eventAddedToVelocityTracker = false;

    final int action = e.getActionMasked();
    final int actionIndex = e.getActionIndex();

    if (action == MotionEvent.ACTION_DOWN) {
        mNestedOffsets[0] = mNestedOffsets[1] = 0;
    }
    final MotionEvent vtev = MotionEvent.obtain(e);
    vtev.offsetLocation(mNestedOffsets[0], mNestedOffsets[1]);

    switch (action) {
        //DOWN 事件主要和嵌套滑动相关,所以这里可以跳过
        case MotionEvent.ACTION_DOWN: {
            mScrollPointerId = e.getPointerId(0);
            mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
            mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);

            int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
            if (canScrollHorizontally) {
                nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
            }
            if (canScrollVertically) {
                nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
            }
            startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
        }
        break;

        case MotionEvent.ACTION_POINTER_DOWN: {
            mScrollPointerId = e.getPointerId(actionIndex);
            mInitialTouchX = mLastTouchX = (int) (e.getX(actionIndex) + 0.5f);
            mInitialTouchY = mLastTouchY = (int) (e.getY(actionIndex) + 0.5f);
        }
        break;

        case MotionEvent.ACTION_MOVE: {
            final int index = e.findPointerIndex(mScrollPointerId);
            if (index < 0) {
                Log.e(TAG, "Error processing scroll; pointer index for id "
                        + mScrollPointerId + " not found. Did any MotionEvents get skipped?");
                return false;
            }
            //这里主要计算手指滑动距离
            final int x = (int) (e.getX(index) + 0.5f);
            final int y = (int) (e.getY(index) + 0.5f);
            int dx = mLastTouchX - x;
            int dy = mLastTouchY - y;
            //mScrollState的默认值是SCROLL_STATE_IDLE,
            //从名字上能看出来跟滑动状态相关,现在还没开始滑动,所以是默认值
            //通过查看mScrollState所有赋值的地方都是在滑动的逻辑
            //所以这个if条件成立
            if (mScrollState != SCROLL_STATE_DRAGGING) {
                boolean startScroll = false;
                //我们以垂直滑动距离
                if (canScrollHorizontally) {
                    if (dx > 0) {
                        dx = Math.max(0, dx - mTouchSlop);
                    } else {
                        dx = Math.min(0, dx + mTouchSlop);
                    }
                    if (dx != 0) {
                        startScroll = true;
                    }
                }
                if (canScrollVertically) {
                    //这里主要是过滤没有达到最小滑动距离的滑动
                    if (dy > 0) {
                        dy = Math.max(0, dy - mTouchSlop);
                    } else {
                        dy = Math.min(0, dy + mTouchSlop);
                    }
                    //滑动距离达标
                    if (dy != 0) {
                        startScroll = true;
                    }
                }
                //判断当前是否应该滑动
                if (startScroll) {
                    //把标志位设置为应该滑动的状态,下面紧接着会用到
                    setScrollState(SCROLL_STATE_DRAGGING);
                }
            }
            //上面已经把mScrollState设置为SCROLL_STATE_DRAGGING
            //判断条件成立
            if (mScrollState == SCROLL_STATE_DRAGGING) {
                mReusableIntPair[0] = 0;
                mReusableIntPair[1] = 0;
                dx -= releaseHorizontalGlow(dx, e.getY());
                dy -= releaseVerticalGlow(dy, e.getX());
                //这里和嵌套滑动相关,可以跳过
                if (dispatchNestedPreScroll(
                        canScrollHorizontally ? dx : 0,
                        canScrollVertically ? dy : 0,
                        mReusableIntPair, mScrollOffset, TYPE_TOUCH
                )) {
                    dx -= mReusableIntPair[0];
                    dy -= mReusableIntPair[1];
                    // Updated the nested offsets
                    mNestedOffsets[0] += mScrollOffset[0];
                    mNestedOffsets[1] += mScrollOffset[1];
                    // Scroll has initiated, prevent parents from intercepting
                    getParent().requestDisallowInterceptTouchEvent(true);
                }

                mLastTouchX = x - mScrollOffset[0];
                mLastTouchY = y - mScrollOffset[1];
                //① 这里是重点了
                if (scrollByInternal(
                        canScrollHorizontally ? dx : 0,
                        canScrollVertically ? dy : 0,
                        e, TYPE_TOUCH)) {
                    getParent().requestDisallowInterceptTouchEvent(true);
                }
                if (mGapWorker != null && (dx != 0 || dy != 0)) {
                    mGapWorker.postFromTraversal(this, dx, dy);
                }
            }
        }
        break;

        ...
        ...

    }

    if (!eventAddedToVelocityTracker) {
        mVelocityTracker.addMovement(vtev);
    }
    vtev.recycle();

    return true;
}

RV.scrollByInternal()

boolean scrollByInternal(int x, int y, MotionEvent ev, int type) {
    int unconsumedX = 0;
    int unconsumedY = 0;
    int consumedX = 0;
    int consumedY = 0;
    //这个函数主要是为了处理当在滑动的时候,adapter数据更新的情况
    //滑动的时候默认数据是不变的,但是adapter实际修改了数据,就会有crash
    consumePendingUpdateOperations();
    if (mAdapter != null) {
        mReusableIntPair[0] = 0;
        mReusableIntPair[1] = 0;
        //① 这里又是重点了,滑动时的layout逻辑都在这里
        scrollStep(x, y, mReusableIntPair);
        consumedX = mReusableIntPair[0];
        consumedY = mReusableIntPair[1];
        unconsumedX = x - consumedX;
        unconsumedY = y - consumedY;
    }
    //后面都是和滑动嵌套相关的逻辑,可以直接跳过
    if (!mItemDecorations.isEmpty()) {
        invalidate();
    }

    mReusableIntPair[0] = 0;
    mReusableIntPair[1] = 0;
    dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
            type, mReusableIntPair);
    unconsumedX -= mReusableIntPair[0];
    unconsumedY -= mReusableIntPair[1];
    boolean consumedNestedScroll = mReusableIntPair[0] != 0 || mReusableIntPair[1] != 0;

    // Update the last touch co-ords, taking any scroll offset into account
    mLastTouchX -= mScrollOffset[0];
    mLastTouchY -= mScrollOffset[1];
    mNestedOffsets[0] += mScrollOffset[0];
    mNestedOffsets[1] += mScrollOffset[1];

    if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
        if (ev != null && !MotionEventCompat.isFromSource(ev, InputDevice.SOURCE_MOUSE)) {
            pullGlows(ev.getX(), unconsumedX, ev.getY(), unconsumedY);
            // For rotary encoders, we release stretch EdgeEffects after they are pulled, to
            // avoid the effects being stuck pulled.
            if (Build.VERSION.SDK_INT >= 31
                    && MotionEventCompat.isFromSource(ev, InputDevice.SOURCE_ROTARY_ENCODER)) {
                releaseGlows();
            }
        }
        considerReleasingGlowsOnScroll(x, y);
    }
    if (consumedX != 0 || consumedY != 0) {
        dispatchOnScrolled(consumedX, consumedY);
    }
    if (!awakenScrollBars()) {
        invalidate();
    }
    return consumedNestedScroll || consumedX != 0 || consumedY != 0;
}

其实这里面大部分逻辑都是处理嵌套滑动的,有layout相关的逻辑都在scrollStep()

LLM.scrollStep()

void scrollStep(int dx, int dy, @Nullable int[] consumed) {
    startInterceptRequestLayout();
    onEnterLayoutOrScroll();

    TraceCompat.beginSection(TRACE_SCROLL_TAG);
    fillRemainingScrollValues(mState);

    int consumedX = 0;
    int consumedY = 0;
    //这里就是根据滑动方向进行滑动处理
    if (dx != 0) {
        consumedX = mLayout.scrollHorizontallyBy(dx, mRecycler, mState);
    }
    if (dy != 0) {
        consumedY = mLayout.scrollVerticallyBy(dy, mRecycler, mState);
    }

    TraceCompat.endSection();
    repositionShadowingViews();

    onExitLayoutOrScroll();
    stopInterceptRequestLayout(false);

    if (consumed != null) {
        consumed[0] = consumedX;
        consumed[1] = consumedY;
    }
}

scrollStep()主要处理两件事,将滑动委托给LayoutManager和其他…

LLM.scrollVerticallyBy

public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
        RecyclerView.State state) {
    if (mOrientation == HORIZONTAL) {
        return 0;
    }
    //交给scrollBy()处理
    return scrollBy(dy, recycler, state);
}

int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
    if (getChildCount() == 0 || delta == 0) {
        return 0;
    }
    ensureLayoutState();
    mLayoutState.mRecycle = true;
    //detail > 0 代表往上滑或者往左滑
    final int layoutDirection = delta > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
    final int absDelta = Math.abs(delta);
    //通过滑动方向来更新layoutState中的各种参数
    updateLayoutState(layoutDirection, absDelta, true, state);
    //开始填充child
    final int consumed = mLayoutState.mScrollingOffset
            + fill(recycler, mLayoutState, state, false);
    if (consumed < 0) {
        if (DEBUG) {
            Log.d(TAG, "Don't have any more elements to scroll");
        }
        return 0;
    }
    final int scrolled = absDelta > consumed ? layoutDirection * consumed : delta;
    mOrientationHelper.offsetChildren(-scrolled);
    if (DEBUG) {
        Log.d(TAG, "scroll req: " + delta + " scrolled: " + scrolled);
    }
    mLayoutState.mLastScrollDelta = scrolled;
    return scrolled;
}

这里的重点逻辑又进入到fill()这里的fill()和静态布局时的逻辑差别不大,不同的是这里设计Viewholder的回收,这里的回收逻辑都在recycleByLayoutState()

LLM.fill()

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
        RecyclerView.State state, boolean stopOnFocusable) {
    // max offset we should set is mFastScroll + available
    final int start = layoutState.mAvailable;
    //①这里的逻辑就是先判断哪些child会被划出屏幕,然后把他们进行回收
    if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
        // TODO ugly bug fix. should not happen
        if (layoutState.mAvailable < 0) {
            layoutState.mScrollingOffset += layoutState.mAvailable;
        }
        recycleByLayoutState(recycler, layoutState);
    }
    //计算总共有多少空间可以用来摆放child
    int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
    //一个用来保存每次布局一个child的结果类,比如一个child消费了多少空间
    //是否应该真实的计算这个child消费的空间(预布局的时候有些child虽然消费了空间,
    // 但是不应该不参与真正的空间剩余空间的计算)
    LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
    //只要还有空间和item就进行布局layoutchunk
    while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
        //重置上一次布局child的结果
        layoutChunkResult.resetInternal();
        if (RecyclerView.VERBOSE_TRACING) {
            TraceCompat.beginSection("LLM LayoutChunk");
        }
        //这里是真正layout child的逻辑
        layoutChunk(recycler, state, layoutState, layoutChunkResult);
        if (RecyclerView.VERBOSE_TRACING) {
            TraceCompat.endSection();
        }
        if (layoutChunkResult.mFinished) {
            break;
        }
        //layoutState.mLayoutDirection的值是 1或者-1 所以这里是 乘法
        //如果是从顶部往底部填充,当前填充的是第三个child 且每个高度是10dp,那么layoutState.mOffset的值
        //就是上次填充时的偏移量 + 这次填充child的高度
        //如果是从底部往顶部填充,那就是次填充时的偏移量 - 这次填充child的高度
        layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
        /**
         * Consume the available space if:
         * * layoutChunk did not request to be ignored
         * * OR we are laying out scrap children
         * * OR we are not doing pre-layout
         */
        //判断是否要真正的消费当前child参与布局所消费的高度
        //从判断条件中可以看到预布局和这个有关,不过预布局等后面几章会详细说的
        //这里就是同步目前还剩多少空间可以用来布局
        if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null
                || !state.isPreLayout()) {
            layoutState.mAvailable -= layoutChunkResult.mConsumed;
            // we keep a separate remaining space because mAvailable is important for recycling
            remainingSpace -= layoutChunkResult.mConsumed;
        }

        //在这个判断内执行滑出去的child进行回收
        if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
            layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
            if (layoutState.mAvailable < 0) {
                layoutState.mScrollingOffset += layoutState.mAvailable;
            }
            //执行回收相关逻辑
            recycleByLayoutState(recycler, layoutState);
        }
        if (stopOnFocusable && layoutChunkResult.mFocusable) {
            break;
        }
    }
    if (DEBUG) {
        validateChildOrder();
    }
    return start - layoutState.mAvailable;
}

这里的逻辑几乎与 Recyclerview源码分析:一、静态时如何布局 一致,但是他在执行逻辑的开始进行了回收逻辑,并且在每次布局完一个child也会再次判断哪些child需要回收.

LLM.recycleByLayoutState()

private void recycleByLayoutState(RecyclerView.Recycler recycler, LayoutState layoutState) {
    if (!layoutState.mRecycle || layoutState.mInfinite) {
        return;
    }
    int scrollingOffset = layoutState.mScrollingOffset;
    int noRecycleSpace = layoutState.mNoRecycleSpace;
    //这里我们还是以垂直布局手指向上滑动场景为例
    //因为手指向上滑动,就需要在底部填充child,所以layoutState.mLayoutDirection != LayoutState.LAYOUT_START
    //就会走到else逻辑中
    if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
        recycleViewsFromEnd(recycler, scrollingOffset, noRecycleSpace);
    } else {
        //①
        recycleViewsFromStart(recycler, scrollingOffset, noRecycleSpace);
    }
}

private void recycleViewsFromStart(RecyclerView.Recycler recycler, int scrollingOffset,
        int noRecycleSpace) {
    if (scrollingOffset < 0) {
        if (DEBUG) {
            Log.d(TAG, "Called recycle from start with a negative value. This might happen"
                    + " during layout changes but may be sign of a bug");
        }
        return;
    }
    // ignore padding, ViewGroup may not clip children.
    //举个栗子:屏幕高度100dp,每个child高度为15dp,此时屏幕会显示
    //7个child,但是第7个child没有全部显示,底部还有5dp的内容在屏幕
    //下方,这时候scrollingOffset=5dp
    final int limit = scrollingOffset - noRecycleSpace;
    final int childCount = getChildCount();
    if (mShouldReverseLayout) {
        for (int i = childCount - 1; i >= 0; i--) {
            View child = getChildAt(i);
            if (mOrientationHelper.getDecoratedEnd(child) > limit
                    || mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
                // stop here
                recycleChildren(recycler, childCount - 1, i);
                return;
            }
        }
    } else {
        //从顶部第一个child开始找,找到第一个child的bottom>scrollingOffset(5dp)的child
        //那么这个child之前的所有child在这次滑动中都会划出屏幕
        //所以要把他们都回收掉
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            if (mOrientationHelper.getDecoratedEnd(child) > limit
                    || mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
                // stop here
                recycleChildren(recycler, 0, i);
                return;
            }
        }
    }
}

总结一下就是:RV在滑动的时候会把滑动的距离交给LayoutManager处理和消费,LayoutManager会遍历当前所有的child并根据他们的位置加上这次滑动距离,判断哪些child会划出屏幕,然后就把他们给回收掉.

Android 学习笔录

Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap文章来源地址https://www.toymoban.com/news/detail-422742.html

到了这里,关于RecyclerView 滑动布局源码分析:带你深入掌握列表滑动机制的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • Android之RecyclerView仿ViewPage滑动

    我们都知道ViewPage+Fragment滑动,但是的需求里面已经有了这玩意,但是在Fragment中还要有类似功能,这时我相信很多人就苦恼了,没事,这张来解决,用RecyclerView去实现即可,而且还带指示器。 这里我没有弄GIF,反正效果和ViewPage+Fragment是一样的。 代码如下(示例): 一个是

    2024年02月09日
    浏览(44)
  • Android 自动滚动的RecyclerView,手动滑动和自动滑动无缝衔接,手动滑动时数据不重复

    概要 做一个自动滑动的列表,用于展示聊天记录或者通知栏信息等,还是使用主流的RecyclerView来做。网上有很多案例,但当手动滑动时会一直无限循环,数据重复的出现,如果想要自动滑动时能无限循环,手动滑动时又能滑到底呢?本案例就解决这种手动滑动和自动滑动无缝

    2024年01月23日
    浏览(49)
  • android:RecyclerView交互动画(上下拖动,左右滑动删除)

    @Override public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) { //监听侧滑;1.删除数据,2.调用adapter.notifyItemRemoved(position) mMoveCallback.onItemRemove(viewHolder.getAdapterPosition()); } //改变选中的Item @Override public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) { //判断状态 if

    2024年04月12日
    浏览(48)
  • Android之解决RecyclerView与NestedScrollView的滑动冲突方法

    问题一:当我们滑动RecyclerView组件时,上方的轮播图并没有进行滑动(NestedScrollView没有滑动,即滑动事件被RecyclerView消费了), 当RecyclerView滑到底时,轮播图部分才进行滑动 。 如下图,RecyclerView已经进行了滑动,但轮播图部分没有。 整体布局 这并不符合我们的设计要求,

    2024年02月16日
    浏览(50)
  • 深入源码解析 ReentrantLock、AQS:掌握 Java 并发编程关键技术

    🔭 嗨,您好 👋 我是 vnjohn,在互联网企业担任 Java 开发,CSDN 优质创作者 📖 推荐专栏:Spring、MySQL、Nacos、Java,后续其他专栏会持续优化更新迭代 🌲文章所在专栏:JUC 🤔 我当前正在学习微服务领域、云原生领域、消息中间件等架构、原理知识 💬 向我询问任何您想要的

    2024年02月11日
    浏览(51)
  • 一文带你深入了解算法笔记中的前缀与差分(附源码)

    📖作者介绍:22级树莓人(计算机专业),热爱编程<目前在c++阶段, 因为最近参加新星计划算法赛道(白佬),所以加快了脚步,果然急迫感会增加动力 ——目标Windows,MySQL,Qt,数据结构与算法,Linux,多线程,会持续分享学习成果和小项目的 📖作者主页:热爱编程的

    2023年04月12日
    浏览(75)
  • 解决Android中使用RecyclerView滑动时底部item显示不全的问题

    感觉这个bug是不是因人而异啊,找了很多文章都没能解决我的问题,包括在RecyclerView上在嵌套上一层RelativeLayout,添加属性android:descendantFocusability=”blocksDescendants”,使用ConstraintLayout布局包裹RecyclerView,再设置layout_height=\\\"0dp\\\"和layout_constraintBottom_toBottomOf=\\\"parent\\\"(就是指定约束

    2024年02月16日
    浏览(42)
  • Android RecyclerView 之 列表宫格布局的切换

    RecyclerView 的使用我就不再多说,接下来的几篇文章主要说一下 RecyclerView 的实用小功能,包括 列表宫格的切换,吸顶效果,多布局效果等,今天这篇文章就来实现一下列表宫格的切换,效果如下 数据来源于知乎日报API,采用 okhttp+retrofit 组合方式请求获取,网络请求没有进行

    2024年02月10日
    浏览(47)
  • 【HashMap1.8源码】十分钟带你深入HashMap1.8源码逐行解析

    四个点核心点 初始化 PUT 扩容 GET Node结构 transient NodeK,V[] table; 初始化时为空的Node数组 Treenode结构 四个构造方法 initialCapacity:初始容量,默认是 tableSizeFor (initialCapacity),根据传参找一个大于该数的2次幂数,比如定义是10,则初始化是16 loadFactor:负载因子,this.loadFactor = DEF

    2024年02月15日
    浏览(54)
  • 【Android笔记97】Android之RecyclerView使用GridLayoutManager网格布局

    这篇文章,主要介绍Android之RecyclerView使用GridLayoutManager网格布局。 目录 一、GridLayoutManager网格布局 1.1、功能效果 1.2、案例代码 (1)创建网格布局

    2024年02月15日
    浏览(40)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包