学会自定义LayoutManager

news2024/11/15 7:27:45

最开始我在学习自定义LayoutManager的时候,也是网上搜文章,看博客,以及一些公众号的推文。刚开始看的时候觉得还是那么回事,但是在慢慢的深入LayoutManager源码才发现很多的文章其实都是不合格,乃至可以说是很误导人的,所以我才想自己写一篇关于自定义LayoutManager的文章,希望能帮助到一部分人能入门自定义LayoutManager吧。

一些自定义LayoutManager优秀文章推荐

前面虽然说有很多博客是不合格的,但是同样还是有一些优秀的作者的博客对在下启发很大,特别是Dave大神building-a-recyclerview-layoutmanager-part的系列的文章,真的是讲得不能再棒了!虽然已经是14年的文章,但是放在当下来看,依然是自定义LayoutManager相关文章的顶峰,虽然文章是英文,但是还是强烈推荐阅读!

Building a RecyclerView LayoutManager – Part 1

Building a RecyclerView LayoutManager – Part 2

Building a RecyclerView LayoutManager – Part 3

无意中发现了有B站大佬翻译了Dave大神讲解自定义LayoutManager的培训视频,这简直是宝藏,建议收藏多次观看。

Mastering RecyclerView Layouts

其次就是张旭童在CSDN发布掌握自定义LayoutManager相关博客,特别是文章中的常见误区和注意事项,建议多次阅读。

https://blog.csdn.net/zxt0601/article/details/52948009

特别是这句话道出了自定义LayoutManager的真谛:一个合格的LayoutManager,childCount数量不应大于屏幕上显示的Item数量,而scrapCache缓存区域的Item数量应该是0,这里建议多读几遍,加深理解。

最后就是陈小媛Android自定义LayoutManager第十一式之飞龙在天,这么大佬的思路总是那么奇特,逻辑总是那么清晰。

https://blog.csdn.net/u011387817/article/details/81875021

先讲讲自定义LayoutManager的常规套路

  1. 继承RecyclerView.LayoutManager并实现generateDefaultLayoutParams()方法。
  2. 按需,重写onMeasure()isAutoMeasureEnabled()方法。
  3. 重写onLayoutChildren()开始第一次填充itemView。
  4. 重写canScrollHorizontally()canScrollVertically()方法支持滑动。
  5. 重写scrollHorizontallyBy()scrollVerticallyBy()方法在滑动的时候填充和回收itemView。
  6. 重写scrollToPosition()smoothScrollToPosition()方法支持。
  7. 解决软键盘弹出或收起导致onLayoutChildren()方法被重新调用的问题。

再说说自定义LayoutManager容易进入的误区

  1. 使用RecyclerView或者说继承了LayoutManager就自带了复用机制和视图回收
  2. 未正确的重写onMeasure()isAutoMeasureEnabled()方法
  3. onLayoutChildren()时直接加载了全部itemView
  4. 未支持scrollToPosition()smoothScrollToPosition()方法
  5. 未解决软键盘弹出或收起onLayoutChildren()方法重新调用的问题。
使用RecyclerView或者说继承了LayoutManager就自带了复用机制和视图回收?

我发现很多人把RecyclerView想得太完美了,认为RecyclerView天生就自带了复用机制和视图回收,只要使用RecyclerView根本不用关心加载item的数量。其实不用很仔细的阅读RecyclerView的源码也能发现,RecyclerView只不过是一个提供了多级缓存超级ViewGroup而已。并且RecyclerView只是将自己的onLayout方法完全委托给了LayoutManager,所以继承LayoutManager也不会自带复用机制和视图回收。

LinearLayoutManager举例,在LinearLayoutManager源码中有一个recycleByLayoutState()方法,它在滚动填充itemView时调用,用来回收超出屏幕的itemView,所以我们在自定义LayoutManager的时候一定要注意,什么时候该回收itemView是由我们开发者自己决定!

    @Override
    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler,
            RecyclerView.State state) {
                ...
        return scrollBy(dx, recycler, state);
    }    

    int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
      ...
        fill(recycler, mLayoutState, state, false);
      ...
    }

        int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
            RecyclerView.State state, boolean stopOnFocusable) {
      recycleByLayoutState(recycler, layoutState);
      ...
      layoutChunk()
      ...
      recycleByLayoutState(recycler, layoutState); 
    }

         void recycleByLayoutState(){
       ...
       if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
            recycleViewsFromEnd(recycler, scrollingOffset, noRecycleSpace);
        } else {
            recycleViewsFromStart(recycler, scrollingOffset, noRecycleSpace);
        }
     }
      
未正确的重写onMeasure()或isAutoMeasureEnabled()方法

上面常规套路中我写的是按需重写onMeasure()和isAutoMeasureEnabled()方法,为什么是按需呢?因为LayoutMangeronMeasure()有默认实现,并且isAutoMeasureEnabled()默认返回的false。这也是为啥有些博客或者Github的源码这两个方法都没有重写却依然没有问题的原因所在,因为他们直接把RecyclerViewwidthheight都设置成了match_parent。当然如果你能确定你的LayoutManager只支持宽高同时要match_parent才能正常使用,也可以这么搞。

那么问题又来了,什么时候重写onMeasure(),什么时候重写isAutoMeasureEnabled(),或者有没有情况同时重写两个方法呢?这里在我阅读了大量源码和源码注释得出的结论就是:不要同时重写两个方法,因为它们是互斥的,看源码你就懂了。重写onMeasure()的情况也极少,除非像我那个PickerLayoutManger一样,要设置一个绝对的高度给LayoutManager。isAutoMeasureEnabled()是自测量模式,给RecyclerViewwrap_content的用的,如果你的LayoutManager要支持wrap_content那就必须重写。

onLayoutChildren()时直接加载了全部itemView

网上博客以及Github上有一些Demo,普遍存在下面这种写法:

 for (int i = 0; i < getItemCount(); i++) {
            View view = recycler.getViewForPosition(i);
            addView(view);
            ......
 }

有一说一,能写出这种写法的人真的不是在搞笑吗?在onLayoutChildren的时候直接遍历itemCount然后addView,这真的不是在作死吗?就算有缓存第一次遍历还是会全部走一遍onCreateViewHolder啊,自己就不能把itemCount设个大点的数试试会不会卡死UI渲染吗!当我有这种想法,而且还想去评论区讨教讨教的时候,我又发现了上面那种写法的变种,罢了罢了,这喷子不当也罢😏😏😏。

 for (int i = 0; i < getItemCount(); i++) {
            View view = recycler.getViewForPosition(i);
            addView(view);
            ......
              记录一些item的宽高,位置等信息
            .....
            recyler.recycleView(view)
 }

最简单的测试方法就是把itemCount设置为Int.MAX_VALUE,没有异常发生就算OK。

未支持scrollToPosition()或smoothScrollToPosition()方法

这个问题严谨的来讲也不算什么大问题,但是私以为一个合格的LayoutManager的还是应该去适配这两个方法,毕竟RecyclerViewscrollToPosition()smoothScrollToPosition()只是对LayoutManager这两个方法的封装,特别是一些发布到Github上的开源库更应该去适配这两个方法。

未解决软键盘弹出或收起onLayoutChildren()方法重新调用的问题

这个问题我发现大多数的人都没注意到,而且有一些开源库也是存在这个问题的。问题出现的根源就是在当EditText获取到焦点导致软键盘弹起或者收起的时候,LayoutManager会重新回调onLayoutChildren()方法。如果一个LayoutManager的onLayoutChildren方法写得不够合理,就会给使用的人带来困扰,详细的内容会放在下面开始自定义LayoutManager再讲。

话说到这里,我在看LinearLayoutManager源码又一次对Google工程师深深的折服,在LinearLayoutManager的onLayoutChildren方法中有一段代码就是对这种问题的处理,并且还是升级版。

 final View focused = getFocusedChild()
 ...
 else if (focused != null && (mOrientationHelper.getDecoratedStart(focused)
                        >= mOrientationHelper.getEndAfterPadding()
                || mOrientationHelper.getDecoratedEnd(focused)
                <= mOrientationHelper.getStartAfterPadding())) {
      mAnchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
}

assignFromViewAndKeepVisibleRect方法是关键,有兴趣的可以自己去看看源码。大概逻辑就是:获取到RecyclerView中获取到焦点的那个itemView和它的position,并开始计算其位置,让它保持在软键盘上面的可见范围内。

一些有用的Api

在开始自定义LayoutManager前,先解释一些Api的用法,这样可以更快的进入主题。

获取一个View
 val view = recycler.getViewForPosition(position)

这个方法会从Recycler中获取到一个不会为null的View,如果position超过itemCount或小于0,就会直接抛出异常。内部代码逻辑就是从不同的缓存中拿View,有就直接返回这个View,没有就用onCreateViewHolder创建并返回。

Recycler类可以简单理解为一个回收管理站,需要View时就向它要,不需要时就丢给它。

将View添加到RecyclerView中
addDisappearingView(View child)
addDisappearingView(View child, int index)
  
addView(View child)
addView(View child, int index)

addDisappearingView方法主要用于支持预测动画,例如:notifyItemRemoved时的删除动画

addView方法更常用,只要是添加View都需要用到它。

测量View
measureChild(@NonNull View child, int widthUsed, int heightUsed)
measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed)

两个方法都是用来测量View的相关信息,从名字我们也能看出,一个方法是会带上margin计算,另外一个则不会。

widthUsedheightUsed也能从名称看出端倪,一般传0就可以了,跟着LinearLayoutManager写就对了。

**注意:**测量View也不一定要使用这两个方法,在特殊的情况下,也可以自己写测量的方法,比如在StaggeredGridLayoutManager中就是自己重写的测量方法measureChildWithDecorationsAndMargin(),以及我的一个开源库PickerLayoutManager中在onMeasure中直接使用了view.measure()这种原生方法。

摆放View
layoutDecorated(@NonNull View child, int left, int top, int right, int bottom)
layoutDecoratedWithMargins(@NonNull View child, int left, int top, int right,
                int bottom) {

这两个方法没啥好说的,就是对view.layout()的封装,只要写过自定义ViewGroup的人应该也不会陌生。

获取View的相关信息
int getPosition(@NonNull View view) 

获取View的layout position,这个方法十分有用,也没啥人讲到过。

int getDecoratedMeasuredWidth(@NonNull View child)
int getDecoratedMeasuredHeight(@NonNull View child)

获取View的宽高,并且是包含了ItemDecoration的占比。

int getDecoratedTop(@NonNull View child)
int getDecoratedLeft(@NonNull View child)
int getDecoratedRight(@NonNull View child)
int getDecoratedBottom(@NonNull View child)

获取View的left,top,right,bottom距离RecyclerView边缘的距离,同样包含了了ItemDecoration的占比。

移动View
offsetChildrenHorizontal(@Px int dx)
offsetChildrenVertical(@Px int dy)

水平或垂直方向的移动全部子View,看源码得知其实就是遍历调用了子View的offsetTopAndBottomoffsetLeftAndRight方法,这两个方法在自定义ViewGroup移动子View时也经常用到。

回收View
detachAndScrapAttachedViews(@NonNull Recycler recycler)
detachAndScrapView(@NonNull View child, @NonNull Recycler recycler)
detachAndScrapViewAt(int index, @NonNull Recycler recycler)
  
removeAndRecycleAllViews(@NonNull Recycler recycler)
removeAndRecycleView(@NonNull View child, @NonNull Recycler recycler)
removeAndRecycleViewAt(int index, @NonNull Recycler recycler)

上面几个方法只要记住是detachAndScrap开头的就是轻量级的回收View,马上又要添加View回来。removeAndRecycle开头的就是加强版的回收View,当再次添加View回来时会执行onBindViewHolder方法。

我看网上没啥博客真正讲清楚什么时候该用哪个方法来回收View的,这里有个简单的办法区分什么时候该用哪一个回收View的方法,那就是:

  • onLayoutChildren回收View使用detachAndScrap的系列方法,因为onLayoutChildren方法会连续多次调用,detachAndScrap系列的方法就是用在这时候。

  • 滚动发生后要回收超出屏幕不可见的View时用removeAndRecycle的系列方法。

别问我为啥知道,因为我看LinearLayoutManagerStaggeredGridLayoutManager也是这么用的,嘻嘻!read the fucking source code~

OrientationHelper帮助类

img_orientation_helper.png

这个帮助类值得好好夸赞,这也是我在阅读LinearLayoutManager源码时发现的,OrientationHelper是一个抽象类,抽象了大量便利的方法,并且提供了两个静态方法createHorizontalHelpercreateVerticalHelper用来创建相应方向的帮助类供开发者使用。使用OrientationHelper可以大大减少如下我在StackLayoutManager的样板代码。

    /**
     * 移动所有子view
     */
    private fun offsetChildren(amount: Int) {
        if (orientation == HORIZONTAL) {
            offsetChildrenHorizontal(amount)
        } else {
            offsetChildrenVertical(amount)
        }
    }
...
    private fun getTotalSpace(): Int {
        return if (orientation == HORIZONTAL) {
            width - paddingLeft - paddingRight
        } else {
            height - paddingTop - paddingBottom
        }
    }

正式开始自定义LayoutManager

现在我们开始正式讲解如何自定义一个LayoutManager,大概步骤就是如上面自定义LayoutManager的常规套路一样,并且我会用我写的两个开源库PickerLayoutManager和StackLayoutManager来讲解,喜欢的可以star一下。

https://github.com/simplepeng/StackLayoutManagerhttps://github.com/simplepeng/PickerLayoutManager
img_stack_layout_manager.pngimg_picker_layout_manager.png
继承LayoutManager并实现generateDefaultLayoutParams()方法

这没啥好说的,generateDefaultLayoutParams是抽象方法,继承LayoutManager就必须实现,你自定义的LayoutManager的itemView支持啥LayoutParams就写哪种,比如我写的PickerLayoutManager和StackLayoutManager就是不同的实现。

class PickerLayoutManager:: RecyclerView.LayoutManager(){
      override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return if (orientation == HORIZONTAL) {
            RecyclerView.LayoutParams(
                RecyclerView.LayoutParams.WRAP_CONTENT,
                RecyclerView.LayoutParams.MATCH_PARENT
            )
        } else {
            RecyclerView.LayoutParams(
                RecyclerView.LayoutParams.MATCH_PARENT,
                RecyclerView.LayoutParams.WRAP_CONTENT
            )
        }
    }
}
class StackLayoutManager: RecyclerView.LayoutManager(){
      override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(
            ViewGroup.LayoutParams.WRAP_CONTENT,
            ViewGroup.LayoutParams.WRAP_CONTENT
        )
    }
}
重写onMeasure()或isAutoMeasureEnabled()方法。
class PickerLayoutManager:: RecyclerView.LayoutManager(){
      override fun onMeasure(
        recycler: RecyclerView.Recycler,
        state: RecyclerView.State,
        widthSpec: Int,
        heightSpec: Int
    ) {
        if (state.itemCount == 0) {
            super.onMeasure(recycler, state, widthSpec, heightSpec)
            return
        }
        if (state.isPreLayout) return

        //假定每个item的宽高一直,所以用第一个view计算宽高,
        //这种方式可能不太好
        val itemView = recycler.getViewForPosition(0)
        addView(itemView)
        //这里不能用measureChild方法,具体看内部源码实现,内部getWidth默认为0
//        measureChildWithMargins(itemView, 0, 0)
        itemView.measure(widthSpec, heightSpec)
        mItemWidth = getDecoratedMeasuredWidth(itemView)
        mItemHeight = getDecoratedMeasuredHeight(itemView)
        //回收这个View
        detachAndScrapView(itemView, recycler)

        //设置宽高
        setWidthAndHeight(mItemWidth, mItemHeight)
    }
  
      private fun setWidthAndHeight(
        width: Int,
        height: Int
    ) {
        if (orientation == HORIZONTAL) {
            setMeasuredDimension(width * visibleCount, height)
        } else {
            setMeasuredDimension(width, height * visibleCount)
        }
    }
}
class StackLayoutManager: RecyclerView.LayoutManager(){
      override fun isAutoMeasureEnabled(): Boolean {
        return true
    }
}

从上面代码可以看出,PickerLayoutManager重写了onMeasure()StackLayoutManager重写了isAutoMeasureEnabled()方法,跟上面常见误区中的讲得一致。

重写onLayoutChildren()开始填充子View。

从这个方法开始,PickerLayoutManager和StackLayoutManager的套路都是一致的:计算剩余空间->addView()->measureView()->layoutView()。因为都是模仿LinearLayoutManager的写法,所以下面开始只用StackLayoutManager伪代码作代码示例,特别的地方再用不同实现的代码做比较。

记住下面的大多数都是伪代码,不要直接复制运行,因为StackLayoutManager支持的属性很多,包括了如同LinearLayoutManagerreverseLayoutorientation等,并且下面的示例只会讲orientation==HORIZONTAL的代码实现,主要是怕代码逻辑太复杂不好理解,想看具体源码的可以点击上面的源码链接查看。

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {

        //轻量级的将view移除屏幕
        detachAndScrapAttachedViews(recycler)
        //开始填充view

        var totalSpace = width - paddingRight
        var currentPosition = 0

        var left = 0
        var top = 0
        var right = 0
        var bottom = 0
        //模仿LinearLayoutManager的写法,当可用距离足够和要填充
        //的itemView的position在合法范围内才填充View
        while (totalSpace > 0 && currentPosition < state.itemCount) {
            val view = recycler.getViewForPosition(currentPosition)
            addView(view)
            measureChild(view, 0, 0)

            right = left + getDecoratedMeasuredWidth(view)
            bottom = top + getDecoratedMeasuredHeight(view)
            layoutDecorated(view, left, top, right, bottom)

            currentPosition++
            left += getDecoratedMeasuredWidth(view)
              //关键点
            totalSpace -= getDecoratedMeasuredWidth(view)
        }
        //layout完成后输出相关信息
        logChildCount("onLayoutChildren", recycler)
    }

上面的代码很简单了,相信写过自定义ViewGroup的人都能看懂。上面代码很简单的实现了一个横向的LinearLayoutManager,如图所示:

img_blog_lm_on_layout.png

并且在layout结束后,增加了一个输出childCount的方法。

    private fun logChildCount(tag: String, recycler: RecyclerView.Recycler) {
        Log.d(tag, "childCount = $childCount -- scrapSize = ${recycler.scrapList.size}")
    }

D/onLayoutChildren: childCount = 9 – scrapSize = 0 D/onLayoutChildren: childCount = 9 – scrapSize = 0 D/onLayoutChildren: childCount = 9 – scrapSize = 0

从图中可以看出,我们摆放了position0-8的itemView,所以childCount=9,并且scrapSize=0,所以我们这个方法写得是合格的。因为我们用totalSpace > 0作了while表达式的判断,所以并不用关心itemCount有多大。

重写canScrollHorizontally()和canScrollVertically()方法支持滑动。

上面已经初始化摆放了一些itemView,但是RecyclerView还是不能滑动的,不信的可以试试。我们必须重写下面两个方法,RecyclerView才会将滑动的事件交给LayoutManager。

    override fun canScrollHorizontally(): Boolean {
        return orientation == HORIZONTAL
    }

    override fun canScrollVertically(): Boolean {
        return orientation == VERTICAL
    }

没啥好说的,想支持哪个方向的滑动,就返回true。同时返回true都可以,那就是同时支持上下左右滑动,类似Dave大神那种表格类型的LayoutManager。

重写scrollHorizontallyBy()和scrollVerticallyBy()方法在滑动的时候填充view和回收view。
override fun scrollHorizontallyBy(
    dx: Int,
    recycler: RecyclerView.Recycler,
    state: RecyclerView.State
): Int {
    return super.scrollHorizontallyBy(dx, recycler, state)
}

这里先讲一下scrollHorizontallyByscrollVerticallyBy两个滑动方法的概念:

  • 我看一些博客下有些评论说:“实现这两个方法也不能滑动啊!”,说是说这两个方法是滑动的方法,但是其实这两个方法只会返回手指在RecyclerView上的移动距离给我们,就是方法中对应的dxdydx>0就是手指从右滑向左dy>0就是手指从下滑向上,同理dx,dy<0则反,真正移动View的事情还是要开发者自己实现,LinearLayoutManager中就简单的用offsetChildren方法实现的移动。或者也有的评论说:“LayoutManager封装的不够合理,滑动还要我们自己实现!”,讲道理说这种话的小朋友还是世面见少了,他肯定没见过可以斜着拖的LayoutManager,或者在滑动的时候对itemView有种各种变换的LayoutManager,嘻嘻。
  • 两个方法的返回值同样也十分重要,返回值就是让RecyclerView知道LayoutManager真实的滑动距离,return 0时RecyclerView就会展示overScorll状态以及NestedScrolling的后续处理。关于NestedScrolling这点我也没发现有博客讲到,啥?overScorll你也不知道!告辞~

添加offsetChildrenHorizontal方法,支持水平方向的滑动。啥?为啥又是-dx,看看源码或者实验实验不就知道了。

    override fun scrollHorizontallyBy(
        dx: Int,
        recycler: RecyclerView.Recycler,
        state: RecyclerView.State
    ): Int {
        //移动View
        offsetChildrenHorizontal(-dx)
        return dx
    }

gif_blog_lm_sroll_horizontal.gif

就这么简单,我们的LayoutManager已经可以滑动了。但是随之而来又发现一个问题:“滑动只是在已存在的这几个children间滑动”。这不是废话吗,我们都没写填充和回收View的方法,肯定没有新的itemView添加进来呀,超过屏幕的View也不会回收呀。下面开始增加填充View和回收View的代码块。

    override fun scrollHorizontallyBy(
        dx: Int,
        recycler: RecyclerView.Recycler,
        state: RecyclerView.State
    ): Int {

        //填充View
        fill(dx, recycler)
        //移动View
        offsetChildrenHorizontal(-dx)
        //回收View
        recycle(dx, recycler)
      
        //输出children
        logChildCount("scrollHorizontallyBy", recycler)
        return dx
    }

从上面的代码可以看出,在滑动的时候我们真正只做了三件事,填充View-移动View-回收View,一个合格的LayoutManager至少是应该做足这三件事的,并且顺序最好如上面代码一样先填充-再移动-最后回收,当然复杂的情况的LayoutManager可以多加一些条件检测和特殊处理,例如LinearLayoutManager就是先回收-再填充-再回收-最后移动

这里我们先写回收的方法,因为逻辑相对简单点。

    private fun recycle(
        dx: Int,
        recycler: RecyclerView.Recycler
    ) {
        //要回收View的集合,暂存
        val recycleViews = hashSetOf<span><span><span>&lt;</span>View</span><span>&gt;</span></span>()

        //dx&gt;0就是手指从右滑向左,所以要回收前面的children
        if (dx &gt; 0) {
            for (i in 0 until childCount) {
                val child = getChildAt(i)!!
                val right = getDecoratedRight(child)
                //itemView的right&lt;0就是要超出屏幕要回收View
                if (right &gt; 0) break
                recycleViews.add(child)
            }
        }

        //dx&lt;0就是手指从左滑向右,所以要回收后面的children
        if (dx &lt; 0) {
            for (i in childCount - 1 downTo 0) {
                val child = getChildAt(i)!!
                val left = getDecoratedLeft(child)

                //itemView的left&gt;recyclerView.width就是要超出屏幕要回收View
                if (left &lt; width) break
                recycleViews.add(child)
            }
        }

        //真正把View移除掉
        for (view in recycleViews) {
            removeAndRecycleView(view, recycler)
        }
        recycleViews.clear()
    }

gif_blog_lm_sroll_recycle.gif

可以看到我们在拖动是时候,LayoutManager确实回收了超出屏幕的itemView,并且通过查看log可知childCount和scrapSize同样是合格的。

D/scrollHorizontallyBy: childCount = 2 – scrapSize = 0

接下来才是重头戏,如何合理的填充View是一门学问。通过我阅读LinearLayoutManager的源码,也总结出一个套路,那就是:获取锚点View的position计算新的锚点View的position和位置,然后和onLayoutChildren方法一样addViewmeasureViewlayoutView

    private fun fill(dx: Int, recycler: RecyclerView.Recycler): Int {
        //将要填充的position
        var fillPosition = RecyclerView.NO_POSITION
        //可用的空间,和onLayoutChildren中的totalSpace类似
        var availableSpace = abs(dx)
        //增加一个滑动距离的绝对值,方便计算
        val absDelta = abs(dx)

        //将要填充的View的左上右下
        var left = 0
        var top = 0
        var right = 0
        var bottom = 0

        //dx&gt;0就是手指从右滑向左,所以就要填充尾部
        if (dx &gt; 0) {
            val anchorView = getChildAt(childCount - 1)!!
            val anchorPosition = getPosition(anchorView)
            val anchorRight = getDecoratedRight(anchorView)

            left = anchorRight
            //填充尾部,那么下一个position就应该是+1
            fillPosition = anchorPosition + 1

            //如果要填充的position超过合理范围并且最后一个View的
            //right-移动的距离 &lt; 右边缘(width)那就要修正真实能移动的距离
            if (fillPosition &gt;= itemCount &amp;&amp; anchorRight - absDelta &lt; width) {
                val fixScrolled = anchorRight - width
                Log.d("scrollHorizontallyBy", "fill == $fixScrolled")
                return fixScrolled
            }

            //如果尾部的锚点位置减去dx还是在屏幕外,就不填充下一个View
            if (anchorRight - absDelta &gt; width) {
                return dx
            }
        }

        //dx&lt;0就是手指从左滑向右,所以就要填充头部
        if (dx &lt; 0) {
            val anchorView = getChildAt(0)!!
            val anchorPosition = getPosition(anchorView)
            val anchorLeft = getDecoratedLeft(anchorView)

            right = anchorLeft
            //填充头部,那么上一个position就应该是-1
            fillPosition = anchorPosition - 1

            //如果要填充的position超过合理范围并且第一个View的
            //left+移动的距离 &gt; 左边缘(0)那就要修正真实能移动的距离
            if (fillPosition &lt; 0 &amp;&amp; anchorLeft + absDelta &gt; 0) {
                return anchorLeft
            }

            //如果头部的锚点位置加上dx还是在屏幕外,就不填充上一个View
            if (anchorLeft + absDelta &lt; 0) {
                return dx
            }
        }

        //根据限定条件,不停地填充View进来
        while (availableSpace &gt; 0 &amp;&amp; (fillPosition in 0 until itemCount)) {
            val itemView = recycler.getViewForPosition(fillPosition)

            if (dx &gt; 0) {
                addView(itemView)
            } else {
                addView(itemView, 0)
            }

            measureChild(itemView, 0, 0)

            if (dx &gt; 0) {
                right = left + getDecoratedMeasuredWidth(itemView)
            } else {
                left = right - getDecoratedMeasuredWidth(itemView)
            }

            bottom = top + getDecoratedMeasuredHeight(itemView)
            layoutDecorated(itemView, left, top, right, bottom)

            if (dx &gt; 0) {
                left += getDecoratedMeasuredWidth(itemView)
                fillPosition++
            } else {
                right -= getDecoratedMeasuredWidth(itemView)
                fillPosition--
            }

            if (fillPosition in 0 until itemCount) {
                availableSpace -= getDecoratedMeasuredWidth(itemView)
            }
        }

        return dx
    }

上面的代码我故意写得很啰嗦,应该很好理解了。而且聪明的宝宝应该发现了这个fill方法跟onLayoutChildren的方法是很耦合的,其实是可以合并成一个的,就像LinearLayoutManagerfill方法一样。还有就是再次记住上面的代码是用来讲解的伪代码,并不是StackLayoutManager的真实代码,为了容易理解,我删除了大量的检测方法,以及写的非常啰嗦。

gif_blog_lm_sroll_fill.gif

D/scrollHorizontallyBy: childCount = 9 – scrapSize = 0 D/scrollHorizontallyBy: childCount = 10 – scrapSize = 0

现在我们的LayoutManager就以及支持了在滑动的时候填充View和回收View,并且childCount依然是合格的。

剩下的就是边界检测让其支持overScrollMode了,细心的小朋友已经发现fill方法其实有一个Int的返回值,那么现在offsetChildrenscrollHorizontallyBy的返回值都使用fill方法的返回值。

    override fun scrollHorizontallyBy(
        dx: Int,
        recycler: RecyclerView.Recycler,
        state: RecyclerView.State
    ): Int {

        //填充View,consumed就是修复后的移动值
        val consumed = fill(dx, recycler)
        //移动View
        offsetChildrenHorizontal(-consumed)
        //回收View
        recycle(consumed, recycler)

        //输出children
        logChildCount("scrollHorizontallyBy", recycler)
        return consumed
    }

gif_blog_lm_sroll_fill_edge.gif

就这样简单,边缘检测也完成了。

scrollToPosition()和smoothScrollToPosition()方法支持。
适配 scrollToPosition()

源码是最好的老师,我们看看LinearLayoutManagerscrollToPosition()是如何实现的。

    //LinearLayoutManager
        @Override
    public void scrollToPosition(int position) {
        mPendingScrollPosition = position;
        mPendingScrollPositionOffset = INVALID_OFFSET;
        if (mPendingSavedState != null) {
            mPendingSavedState.invalidateAnchor();
        }
        requestLayout();
    }

原来这么简单的吗?再看看mPendingScrollPosition是个啥。

    /**
     * When LayoutManager needs to scroll to a position, it sets this variable and requests a
     * layout which will check this variable and re-layout accordingly.
     */
    int mPendingScrollPosition = RecyclerView.NO_POSITION;

从英文注释的大概的意思就是mPendingScrollPosition是要scorll到的position,那我们继续找它是在哪里调用的。在一连串的搜索后,我发现了华点。

    private boolean updateAnchorFromPendingData(RecyclerView.State state, AnchorInfo anchorInfo) {
        if (state.isPreLayout() || mPendingScrollPosition == RecyclerView.NO_POSITION) {
            return false;
        }
        // validate scroll position
        if (mPendingScrollPosition &lt; 0 || mPendingScrollPosition &gt;= state.getItemCount()) {
            mPendingScrollPosition = RecyclerView.NO_POSITION;
            mPendingScrollPositionOffset = INVALID_OFFSET;
            if (DEBUG) {
                Log.e(TAG, "ignoring invalid scroll position " + mPendingScrollPosition);
            }
            return false;
        }

        // if child is visible, try to make it a reference child and ensure it is fully visible.
        // if child is not visible, align it depending on its virtual position.
        anchorInfo.mPosition = mPendingScrollPosition;
      ...
    }

这个updateAnchorFromPendingData()方法有多层调用栈,但是最终还是在onLayoutChildren()方法中调用的。还记得我们最开始在onLayuoutChildren()有个currentPosition = 0的变量吗,那个变量就相当于这里的anchorInfo.mPosition,就是锚点的position,那么现在我们就可以得出如何适配scrollToPosition的结论:增加mPendingScrollPosition变量,在scrollToPosition()方法中对其赋值,调用requestLayout()方法,然后onLayoutChildren()方法会再次回调,这时对锚点position重新赋值,记住一定做好position的合法校验。

         private var mPendingPosition = RecyclerView.NO_POSITION

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
                ...省略代码
      
        var currentPosition = 0
        if (mPendingPosition != RecyclerView.NO_POSITION){
            currentPosition = mPendingPosition
        }

        ...省略代码
    }

    override fun scrollToPosition(position: Int) {
        if (position &lt; 0 || position &gt;= itemCount) return
        mPendingPosition = position
        requestLayout()
    }

gif_blog_lm_sroll_to_position.gif

仔细看,我们的LayoutManager是不是可以scrollToPosition了。但是这还不是完整的实现,如果你仔细对比LinearLayuotManager的scrollToPosition就能发现差别所在,我这里只是抛砖引玉一下,能让大家知道如何适配scrollToPosition就行了,完整的实现大多数就是细节的处理,和套路无关,听懂掌声👏👏👏👏。

还有一点我看大多数的博客也没讲到,那就是onLayoutCompleted()这个方法其实挺有用的,为啥没人说呢?onLayoutCompleted会在LayoutManager调用完onLayoutChildren()后调用,可以用来做很多收尾的工作。例如:重置mPendingScrollPosition的值

    //LinearLayoutManager
        @Override
    public void onLayoutCompleted(RecyclerView.State state) {
        super.onLayoutCompleted(state);
        mPendingSavedState = null; // we don't need this anymore
        mPendingScrollPosition = RecyclerView.NO_POSITION;
        mPendingScrollPositionOffset = INVALID_OFFSET;
        mAnchorInfo.reset();
    }
适配smoothScrollToPosition()

继续扒LinearLayuotManagersmoothScrollToPosition的源码。

    //LinearLayuotManager
        @Override
    public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state,
            int position) {
        LinearSmoothScroller linearSmoothScroller =
                new LinearSmoothScroller(recyclerView.getContext());
        linearSmoothScroller.setTargetPosition(position);
        startSmoothScroll(linearSmoothScroller);
    }

九折?不管了,直接复制,粘贴,再看效果,毕竟祖传CV工程师岂非浪得虚名。

    override fun smoothScrollToPosition(
        recyclerView: RecyclerView,
        state: RecyclerView.State,
        position: Int
    ) {
        val linearSmoothScroller =
            LinearSmoothScroller(recyclerView.context)
        linearSmoothScroller.targetPosition = position
        startSmoothScroll(linearSmoothScroller)
    }

gif_blog_lm_smooth_sroll_to_position_1.gif

咦!这不是scrollToPosition的效果吗?也不是我们smoothScroll的平滑效果呀。于是我继续看博客,翻源码,也还是看到了Dave大神的博客才找到了真正的重点computeScrollVectorForPosition(int targetPosition)这个方法。这个方法就在LinearLayoutManagersmoothScrollToPosition方法下面,但是没有注释,是真难让人猜。

    @Override
    public PointF computeScrollVectorForPosition(int targetPosition) {
        if (getChildCount() == 0) {
            return null;
        }
        final int firstChildPos = getPosition(getChildAt(0));
        final int direction = targetPosition &lt; firstChildPos != mShouldReverseLayout ? -1 : 1;
        if (mOrientation == HORIZONTAL) {
            return new PointF(direction, 0);
        } else {
            return new PointF(0, direction);
        }
    }

这个computeScrollVectorForPosition方法是SmoothScroller类的一个方法。LinearSmoothScroller又是继承于SmoothScroller

        @Nullable
        public PointF computeScrollVectorForPosition(int targetPosition) {
            LayoutManager layoutManager = getLayoutManager();
            if (layoutManager instanceof ScrollVectorProvider) {
                return ((ScrollVectorProvider) layoutManager)
                        .computeScrollVectorForPosition(targetPosition);
            }
            Log.w(TAG, "You should override computeScrollVectorForPosition when the LayoutManager"
                    + " does not implement " + ScrollVectorProvider.class.getCanonicalName());
            return null;
        }

从源码来看,又在判断LayoutManager是否是ScrollVectorProvider的子类。如果是就执行computeScrollVectorForPosition方法,那么这样来说的话LinearLayoutManager肯定实现了ScrollVectorProvider接口。

public class LinearLayoutManager extends RecyclerView.LayoutManager implements
        ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider {

果然和我们猜想的一样,那么我们也继续模仿这种写法。

class BlogLayoutManager : RecyclerView.LayoutManager() ,RecyclerView.SmoothScroller.ScrollVectorProvider{
          override fun computeScrollVectorForPosition(targetPosition: Int): PointF? {
        if (childCount == 0) {
            return null
        }
        val firstChildPos = getPosition(getChildAt(0)!!)
        val direction = if (targetPosition &lt; firstChildPos) -1 else 1
        return PointF(direction.toFloat(), 0f)
    }
}

gif_blog_lm_smooth_sroll_to_position_2.gif

细心的小朋友又发现了,我们平滑滚动到50这个position,但是50是靠后停止的,并不是滚动到前面边缘的位置停止。没错,正确的效果就是这样,包括LinearLayoutManagersmoothScrollToPosition的效果也是这样。所以前面我才会说scrollToPosition的实现不是完整效果,完整效果应该和smoothScrollToPosition一样,scrollToPosition到后面的position就是应该从后往前填充,scrollToPosition到前面的position才是从前往后填充

接着我们讲讲computeScrollVectorForPosition这个方法里面的实现套路。

val firstChildPos = getPosition(getChildAt(0)!!)
val direction = if (targetPosition &lt; firstChildPos) -1 else 1
return PointF(direction.toFloat(), 0f)

通过我阅读源码注释得知,重点就在这个PointF的返回值,源码注释中告诉我们向量的大小并不重要,重要的是targetPosition向量的方向PointFx代表水平方向,y代表竖直方向。整数代表正向移动,负数代表反向移动,也就是上面代码中的direction。但是其实这个说法也不是全对,如果你需要而且能够算出精确的移动值,那就可以直接传递精确的值给PointF

解决软键盘弹出或收起onLayoutChildren()方法重新调用的问题。

这个问题我也是无意中发现的。

gif_blog_lm_keyborad.gif

如图所示,我们在滚动一段距离后,让软键盘弹出,发现LayoutManager自动回到position=0那里,再滚动一段距离,软键盘收起,LayoutManager又自动回到position=0那里。分析原因可以知道是onLayoutChildren方法被重新调用导致,因为onLayoutChildren方法中我们的currentPosition=0,所以导致了LayoutManager从0开始重新布局。下面我们开始修正position为真实滚动后的值。

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {

        var totalSpace = width - paddingRight

        var currentPosition = 0

        //当childCount != 0时,证明是已经填充过View的,因为有回收
        //所以直接赋值为第一个child的position就可以
        if (childCount != 0) {
            currentPosition = getPosition(getChildAt(0)!!)
        }

        if (mPendingPosition != RecyclerView.NO_POSITION) {
            currentPosition = mPendingPosition
        }

        //轻量级的将view移除屏幕
        detachAndScrapAttachedViews(recycler)

        //开始填充view
        var left = 0
      ...省略代码
    }

上面示例代码注意detachAndScrapAttachedViews(recycler)方法是在修正position方法的后面,因为先调用detachAndScrapAttachedViews后,childCount就会一直为0啊!

gif_blog_lm_keyborad_fix.gif

还是如图所示,我们拖动到了position=25的itemView那里,然后软键盘弹起onLayoutChildren调用,这次的确是从currentPosition=25开始重新布局。

但是现在这个方法也还是有瑕疵,仔细看图,我们发现position=25的itemView明明被拖动了一般的宽度到屏幕外,但是重新onLayoutChildren时,又是从屏幕左边缘开始layoutView了。那么怎么解决呢?我们还是可以学习LinearLayoutManager的解决办法,开始获取一个fixOffset的值,在重新layout结束去移动这个值的距离,LinearLayoutManager是将滑动,填充,回收封装成了一个scrollBy()方法,然后在layout结束调用scrollBy方法去修正偏移量,这么做可以解决偏移滑动的同时填充和回收View,我这里偷个懒,直接用offsetChildren去修正一下偏移量。

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {

        var totalSpace = width - paddingRight

        var currentPosition = 0
        var fixOffset = 0

        //当childCount != 0时,证明是已经填充过View的,因为有回收
        //所以直接赋值为第一个child的position就可以
        if (childCount != 0) {
            currentPosition = getPosition(getChildAt(0)!!)
            fixOffset = getDecoratedLeft(getChildAt(0)!!)
        }
                //...省略代码
        offsetChildrenHorizontal(fixOffset)
    }

gif_blog_lm_keyborad_fix_2.gif

OK,收工!啥?要实现的一个`StackLayoutManager`,为啥你这个是`LinearLayoutManger`!都看到这里了如果你还能有这种问题,证明我写了一篇水文,逃

上面的伪代码示例

最后

学习自定义LayoutManager的收获挺多的,特别是一些逻辑上的处理,由衷的佩服RecyclerView的作者,真的啥情况都考虑到了。虽然说日常使用RecyclerView自带的那几个LayoutManager就够用了,但是学习一下自定义LayoutManager也不妨,而且深入了还可以同时加深对RecyclerView的理解,何乐而不为呢~

从开始学习自定义LayoutManager,到写了几个开源库,再到完成这篇文章,断断续续花了一个多月吧,如果你觉得这篇文章有帮助你,帮忙给文章点个赞或者给开源库一个star吧,让我知道付出还是会有收获的,谢谢~

https://github.com/simplepeng/StackLayoutManager

https://github.com/simplepeng/PickerLayoutManager

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2095167.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

vue3+elementplus的表格展示和分页实战

文章目录 一、Element Plus的安装使用二、el-table 表格组件三、el-pagination 分页组件四、全部代码五、效果 Element Plus 是一个基于 Vue 3 的现代化 UI 组件库&#xff0c;旨在帮助开发者快速构建美观且功能丰富的 Web 应用程序。它提供了大量的 UI 组件&#xff0c;如按钮…

leetcode3 无重复字符的最长子串

思路 双指针 易错点 什么时候更新长度 除了每次有重复的要更新 &#xff0c;如果abc这种&#xff0c;本身就不会重复&#xff0c;那maxLength就永远不会更新了。 思路不对 left不应该是1&#xff0c;对于abca&#xff0c;是1 对于 abcc,应该直接加3&#xff0c;所以需要记…

Python 从入门到实战4(序列的操作)

我们的目标是&#xff1a;通过这一套资料学习下来&#xff0c;通过熟练掌握python基础&#xff0c;然后结合经典实例、实践相结合&#xff0c;使我们完全掌握python&#xff0c;并做到独立完成项目开发的能力。 上篇文章我们通过举例学习了python 中列表的简单操作&#xff0c;…

Codeforces Round 107 (Div. 1) A. Win or Freeze (博弈论 + 数论*1400)

如果一个数是质数或者是1&#xff0c;那么一定是先手获胜&#xff0c;因为不能操作。 我们知道&#xff0c;一个数一定可以由 p 1 k 1 ∗ p 2 k 2 ∗ p 3 k 3 … p_{1}^{k_1}*p_{2}^{k_2}*p_3^{k_3}\dots p1k1​​∗p2k2​​∗p3k3​​…来唯一表示&#xff0c;那么我们就可以…

从入门到精通:掌握 CMD 与 PowerShell 之间的秘密

在日常使用 Windows 系统时&#xff0c;很多人都习惯于打开 CMD&#xff08;命令提示符&#xff09;来执行各种操作&#xff0c;从简单的文件管理到复杂的脚本编写&#xff0c;CMD 作为一个经典的工具确实陪伴我们走过了漫长的岁月。然而&#xff0c;随着系统管理需求的提升和自…

什么是EDR、NDR、MDR、XDR?他们之间什么区别?

《网安面试指南》http://mp.weixin.qq.com/s?__bizMzkwNjY1Mzc0Nw&mid2247484339&idx1&sn356300f169de74e7a778b04bfbbbd0ab&chksmc0e47aeff793f3f9a5f7abcfa57695e8944e52bca2de2c7a3eb1aecb3c1e6b9cb6abe509d51f&scene21#wechat_redirect 概述 EDR是什…

opencv之图像平滑处理

文章目录 前言1.什么是平滑处理2.均值滤波2.1基本原理2.1 函数语法 3.方框滤波3.1基本原理3.2函数语法 4.高斯滤波4.1基本原理4.2函数语法 5.中值滤波5.1基本原理5.2 函数语法 6.双边滤波6.1基本原理 温馨提示 前言 **图像平滑处理是图像处理和计算机视觉领域中的一个核心技术…

yolov8改进策略,有可以直接用的代码,80余种改进策略,有讲解

YOLOv8改进策略介绍 YOLOv8是在YOLOv7的基础上进一步发展的目标检测模型&#xff0c;继承了YOLO系列模型的优点&#xff0c;如实时性、准确性和灵活性。然而&#xff0c;任何模型都有进一步改进的空间&#xff0c;以提高其性能、准确性和鲁棒性。下面是针对YOLOv8的一些改进策…

电容的分类

电容作为电子产品中不可或缺的元件&#xff0c;其种类繁多&#xff0c;各具特色。以下是电容的主要分类、作用及优缺点概述&#xff1a; 一、电容的分类 电容的分类方式多样&#xff0c;常见的分类方式包括按结构、用途、电解质类型及制造材料等。 按结构分类&#xff1a; 固…

搞懂奇偶校验

当我们有一串二进制的数据时&#xff0c;如何在这串二进制数据的最前面&#xff0c;或者最后面&#xff0c;添加一位的奇检验位或者偶校验位呢&#xff1f; &#xff08;1&#xff09;首先要明确使用什么校验&#xff1a;你使用奇校验&#xff0c;还是偶检验&#xff1f; &am…

使用 EasyExcel 高效读取大文件 Excel

使用 EasyExcel 高效读取大文件 Excel 的最佳实践 在现代应用中&#xff0c;数据处理经常涉及到大规模数据集的处理&#xff0c;Excel 作为一种常见的文件格式&#xff0c;经常用于数据导入和导出。然而&#xff0c;传统的 Excel 处理库如 Apache POI 在处理大文件时可能会面临…

基于Java+SpringBoot+Vue的植物健康系统

基于JavaSpringBootVue的植物健康系统 前言 ✌全网粉丝20W,csdn特邀作者、博客专家、CSDN[新星计划]导师、java领域优质创作者,博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java技术领域和毕业项目实战✌ &#x1f345; 某信 gzh 搜索【智能编程小助手】获取项…

(学习总结15)C++11小语法与拷贝问题

C11小语法与拷贝问题 auto关键字范围forinitializer_list深拷贝与浅拷贝写时拷贝 以下代码环境为 VS2022 C。 auto关键字 在早期 C/C 中 auto 的含义是&#xff1a;使用 auto 修饰的变量&#xff0c;是具有自动存储器的局部变量&#xff0c;不过一般都会隐藏&#xff0c;导致…

科研绘图 - Python - 柱状图代码及展示

1 import pandas as pd import matplotlib.pyplot as pltstudents pd.read_excel(./Students.xlsx)print(----原始数据----) print(students)students.sort_values(by2017,inplaceTrue,ascendingFalse) students.plot.bar(xField,y[2016,2017],color[orange,red])plt.title(I…

【Redis】Redis 客户端开发与 Java 集成:RESP协议解析与实战操作

目录 客⼾端Redis Java使⽤ 样例列表引⼊依赖配置端⼝转发连接 Redis Server基础操作字符串操作列表操作哈希表操作集合操作有序集合操作访问集群 Redis Java 集成到 Spring Boot使⽤ Spring Boot 连接 Redis 单机创建项⽬配置 redis 服务地址创建 Controller使⽤ String使⽤ L…

华为OD机试真题 - 分割均衡字符串 - 贪心算法(Python/JS/C/C++ 2024 D卷 100分)

华为OD机试 2024E卷题库疯狂收录中,刷题点这里 专栏导读 本专栏收录于《华为OD机试真题(Python/JS/C/C++)》。 刷的越多,抽中的概率越大,私信哪吒,备注华为OD,加入华为OD刷题交流群,每一题都有详细的答题思路、详细的代码注释、3个测试用例、为什么这道题采用XX算法、…

集成电路学习:什么是GPIO通用输入输出

GPIO&#xff1a;通用输入输出 GPIO&#xff0c;全称General Purpose Input/Output&#xff0c;即通用输入/输出端口&#xff0c;是嵌入式系统中非常重要的基本硬件资源之一。以下是对GPIO的详细解析&#xff1a; 一、GPIO的定义与功能 GPIO是一种非常灵活的接口&#xff0c;可…

ping不通本地虚拟机的静态ip的解决方案

找到网络配置文件 一般我们设置虚拟机文件为静态IP地址&#xff0c; 比如 /etc/sysconfig/network-scripts/ifcfg-ens33 记住Gateway 192.168.200.2 查看虚拟网络编辑器 把子网ip改为192.168.200.0 前三部分相同&#xff0c;第四部分是0 把nat设置中的网关ip改成Gateway 1…

时空图卷积网络:用于交通流量预测的深度学习框架-1

摘要 准确的交通预测对于城市交通控制和引导至关重要。由于交通流的高度非线性和复杂性&#xff0c;传统方法无法满足中长期预测任务的需求&#xff0c;且往往忽略了空间和时间的依赖关系。本文提出一种新的深度学习框架——时空图卷积网络(STGCN)来解决交通领域的时间序列预测…

「MyBatis」图书管理系统 v1.0

&#x1f387;个人主页&#xff1a;Ice_Sugar_7 &#x1f387;所属专栏&#xff1a;JavaEE &#x1f387;欢迎点赞收藏加关注哦&#xff01; 图书管理系统 v1.0 &#x1f349;登录&#x1f349;图书操作&#x1f34c;图书类&#x1f34c;页面信息&#x1f34c;操作 &#x1f34…