• 仿京东拼多多商品分类页-(RecyclerView悬浮头部实现、xml绘制ItemDecoration)


    前言

    做过的功能一定要总结,因为,过段时间你就忘记了哈哈哈
    最近在做功能分类页功能,我看了下,这不和我之前做的美团购物车功能差不多么,然后就再看了遍之前写的文章,并看了下底下的评论,不得不说,当时实现的方式确实复杂,搞得我都有点懵,所以就打算优化下当时实现的方式。

    效果图

    先上张最后实现的效果图吧
    在这里插入图片描述

    思路

    有两种方式

    方式一:通过xml布局来实现

    • 右侧每个标题加下面的分组列表为一个ItemView,直接在该ItemView的xml布局内绘制好即可
    • 悬浮头部是直接在右侧整个RecyclerView上方和他重合,绘制一个固定的头部布局即可

    看下图即可明白该如何来实现,主要的内容是在xml来直接设置好头部及悬浮头部位置布局

    在这里插入图片描述

    具体的实现可以参考Android 仿京东、拼多多商品分类页这篇文章

    优点:

    • 悬浮头部和itemView的头部均可点击
    • 实现便捷

    缺点:

    • 每个ItemView的头部样式都一样,不可以来动态的更改

    方式二:通过ItemDecoration方式来实现

    固定头部通过onDraw()方法来绘制,悬浮头部通过onDrawOver()方法绘制。

    这种方法在Android购物车效果实现(RecyclerView悬浮头部实现)中使用过,原理差不多,但是

    当时写的比较复杂,主要麻烦在两点:

    1、数据项格式太复杂,之前实现的方式是将数据进行整合后,将右侧所有的子项形成一个集合,然后用一个RecyclerView来展示,这样导致左右联动,右侧滑动找左侧父id时,很麻烦。

    2、绘制悬浮头部和各组的标题头时,是在onDraw()onDrawOver()中来绘制的,对于简单的TextView还可以,但是对于一些复杂的头部的话绘制就比较复杂,尤其是不太擅长的小白那就更别说了。

    改善点

    1、使用源数据的分组结构,左右两侧的数据均使用同一集合,右侧列表的ItemView由RecyclerView组成,这样实现了右侧的数据分组,而不再是将数据分开后再重新分组。这样做可以使左右联动更方便,左右联动只需各自将相同位置的ItemView项展示出来即可。

    2、组标题和悬浮头部的绘制使用xml加载布局并在onDraw()onDrawOver()中绘制,可以实现复杂头部简单加载

    难点:

    • 如何使用xml布局来连续绘制到Canvas里
    • onDrawOver()中如何绘制实现悬浮头部

    优点

    • 可以动态给每个ItemView都设置不一样的头部布局
    • 切换头部布局和悬浮头部很方便,解耦,直接替换就好

    缺点:

    • 悬浮头部和子项头部都不能点击

    实现步骤

    这里主要介绍下使用ItemDecoration的方式来绘制分组头部布局的实现方法。

    1、数据项格式

    这里数据使用Android 仿京东、拼多多商品分类页内提供的数据,格式如下

    在这里插入图片描述

    2、左侧列表适配器

    增加点击事件,当点击position位置时,让右侧recyclerView的position项滑动到顶部即可

    leftAdapter.setLeftClickListener(object : LeftAdapter.LeftClickListener {
        override fun onItemClick(position: Int) {
            var layoutManager = binding.rightRcy.layoutManager as LinearLayoutManager
    
            //将position该位置的itemView移动到第一项
            layoutManager.scrollToPositionWithOffset(position, 0)
        }
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    3、右侧列表适配器

    增加滑动监听,实现两个功能:

    • 当滑动时,实时获取右侧第一个可见项所在的位置position,同时将左侧RecyclerView的position项选中
    • 当滑动到底部且无法下滑时,将左侧RecyclerView的最后一项选中
    • 后续可以增加:如果左侧选中项位置太低,将其滑动到上方来的操作
    binding.rightRcy.addOnScrollListener(object :RecyclerView.OnScrollListener(){
    override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
        super.onScrollStateChanged(recyclerView, newState)
    }
    
    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        super.onScrolled(recyclerView, dx, dy)
        //无法下滑,移动到最后时,将左侧列表的最后一项设置为选中
        if (!recyclerView.canScrollVertically(1)) {
            leftAdapter.setSelectedNum(dataList.size-1)
        }
        //右侧列表可以滑动
        else {
            val rightLayoutManager = binding.rightRcy.layoutManager as LinearLayoutManager
            val position = rightLayoutManager.findFirstVisibleItemPosition()
            leftAdapter.setSelectedNum(position)
        }
    }
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    4、头部及悬浮头部绘制

    4.1头部偏移高度为要绘制xml布局的高度–getItemOffsets()

    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State,
    ) {
        super.getItemOffsets(outRect, view, parent, state)
        if (headTitleView == null) {
            headTitleView =
                LayoutInflater.from(parent.context).inflate(R.layout.head_itemview, null, false)
            val width = parent.layoutManager?.width?:0
    
            headTitleView?.measure(
                View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
                View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
            )
        }
        headTitleView?.let {
            //距离ItemView的上方偏移topHeight高度
            outRect.top = it.measuredHeight
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    这里我们首先需要加载headTitleView布局,然后获取该布局的高度,最后通过outRect.top来偏移该布局的高度,后面我们在onDraw()onDrawOver()方法里分别绘制头部和悬浮头部。

    注意:

    • View的宽高属性在measure()方法调用之前都是默认值,不反映实际情况。

      measure()方法是用来测量View的大小的,它会根据父容器传递的限制条件(例如这里的width和height参数)来确定View的实际宽高。

      所以在获取View的宽高之前,需要先调用measure()方法,否则得到的只是默认值,不符合实际需要,调用它之后才能保证后续的宽高数据是准确的。

    • 这里使用layoutManager来获取recyclerview的宽度,因为在此处直接调用parent.width parent.measuredWidth方法获取到的宽度均为0

      • getItemOffsets在RecyclerView完成布局和测量前调用,这时measuredWidth还没准备好,所以获取到的宽度为0
      • layoutManager可以获取到RecyclerView的宽高限制条件spec,知道RecyclerView的宽高限制,所以只能通过layoutManager.width获取宽度,measuredWidth无效
    • 这里的头部布局如下,建议在最外层套一层

      
      <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
          android:layout_width="match_parent"
          android:layout_height="wrap_content">
          <RelativeLayout
              android:layout_width="match_parent"
              android:layout_height="50dp"
              android:background="@drawable/sp_headtitle"
              android:layout_gravity="center_horizontal">
              <ImageView
                  android:layout_width="129dp"
                  android:layout_height="match_parent"
                  android:layout_alignParentRight="true"
                  android:src="@mipmap/ic_bg"
                  android:scaleType="fitXY"/>
              <TextView
                  android:id="@+id/tvTitle"
                  android:layout_width="match_parent"
                  android:layout_height="match_parent"
                  android:gravity="center"
                  android:text="头部标题"
                  android:textColor="@color/black"
                  android:textSize="18sp" />
          RelativeLayout>
      FrameLayout>
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25

      如果按如下方式写,否则会出现下面的情况

      
      <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
          android:layout_width="match_parent"
          android:layout_height="50dp"
          android:layout_gravity="center_horizontal"
          android:background="@drawable/sp_headtitle">
      
          <ImageView
              android:layout_width="129dp"
              android:layout_height="match_parent"
              android:layout_alignParentRight="true"
              android:scaleType="fitXY"
              android:src="@mipmap/ic_bg" />
      
          <TextView
              android:id="@+id/tvTitle"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:gravity="center"
              android:text="头部标题"
              android:textColor="@color/black"
              android:textSize="18sp" />
      RelativeLayout>
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24

      在这里插入图片描述

    • 宽高设置

      val width = parent.layoutManager?.width?:0
      headTitleView?.measure(
          View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
          View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
      )
      
      • 1
      • 2
      • 3
      • 4
      • 5

      宽度:
      因为我们头部布局的父容器为match_parent,且我们想绘制的宽度为占满右侧RecyclerView的宽度,所以这里View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY)View.MeasureSpec.EXACTLY代表精确模式,将其设定为我们获取到的RecyclerView的宽度即可。

      其实使用View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.AT_MOST)也可以,代表子View宽度不确定,但是最大为我们测量的RecyclerView的width即可。

      高度:

      因为我们加载headTitleView后,需要通过measure()方法测量后才可用,所以此时我们并不知道它的具体高度,所以不能用EXACTLY或AT_MOST模式,所以使用UNSPECIFIED,代表父容器不对子View有限制,子View要多大给多大

    4.2 绘制固定头部–onDraw()

    /**
     * 绘制头部
     */
    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDraw(c, parent, state)
        val childCount = parent.childCount
        for (i in 0 until childCount) {
            val child = parent.getChildAt(i)
            val bottom = child.top
    
            headTitleView?.let {
                val top = bottom - it.measuredHeight
                val itemView = parent.getChildAt(i)
                val position = parent.getChildAdapterPosition(itemView)
                //获取该位置的标题名称
                val groupTitleName = titleDataList[position].toUpperCase()
                //设置标题内容
                it.findViewById<TextView>(R.id.tvTitle).text = groupTitleName
    
                // 保存 Canvas 的状态
                c.save()
                // 平移 Canvas,使 View 绘制在正确位置
                c.translate(0f, top.toFloat())
                it.layout(0, top, parent.measuredWidth, bottom)
                it.draw(c)
                c.restore()
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    具体的头部绘制的位置,可参考前两篇文章Android购物车效果实现(RecyclerView悬浮头部实现)

    自定义ItemDecoration分割线的高度、颜色、偏移,看完这个你就懂了

    这里主要讲下注意事项

    • 设置title的名字要在draw()方法之前,不然你都绘制了,还在那设置名字没有意义

    • 因为右侧每个ItemView都是一组数据,该ItemView布局由一个RecyclerView构成,所以需要给每个ItemView都绘制头部布局,getItemOffsets() 是针对每一个 ItemView,而 onDraw()方法却是针对 RecyclerView 本身,所以在onDraw()方法中需要遍历屏幕上可见的ItemView来循环绘制。

    • 这里在绘制前分别调用了translate()layout()方法:
      刚开始是直接调用 headTitleView.draw(canvas),但发现并没有绘制出来,这是因为我们没有将Canvas平移到指定位置,直接绘制的话,头部View会默认绘制在Canvas的(0,0)坐标点,而我们期望它绘制在ItemView的顶部适当位置。通过translate平移和layout重新布局,可以重用同一个头部View来绘制不同Item的头部,避免重复创建View。

    • 这里在绘制前和绘制后分别调用了c.save()c.restore()方法:

      保存Canvas状态可以防止绘制操作对Canvas产生影响,绘制完成后恢复状态可以保证不污染Canvas。

    4.3 绘制悬浮头部-onDrawOver()

     override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
            super.onDrawOver(c, parent, state)
         
            val itemView = parent.getChildAt(0)
            val position = parent.getChildAdapterPosition(itemView)
            var titleName = titleDataList[position].toUpperCase()
    
    
            val left = 0
            val right = parent.measuredWidth
            //默认的指定高度
            var height = headTitleView?.measuredHeight ?: 0
            //当前ItemView的底部
            var bottom = itemView.bottom
            if (bottom<height){
                height=bottom
            }
            headTitleView?.let {
                it.findViewById<TextView>(R.id.tvTitle).text = titleName
                c.save()
                // 平移 Canvas,使 View 绘制在正确位置
                c.translate(0f, (height-it.measuredHeight).toFloat())
                it.layout(left, height-it.measuredHeight, right, height)
                it.draw(c)
                c.restore()
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    通过不断改变绘制的顶部和底部位置来实现被顶出的动画效果,这里不再详细阐述,具体可看Android购物车效果实现(RecyclerView悬浮头部实现)的第4小节
    具体的绘制和onDraw()方法中的绘制流程一致。

    总结

    其实主要还是ItemDecoration相关的内容,相比较Android购物车效果实现(RecyclerView悬浮头部实现)的内容,不同点在于优化了数据项的分组使用和头部绘制使用xml两个地方,所以说做功能前还是要先考虑考虑数据该如何使用,不然会增加很多工作量。

    如果本文对你有帮助,请别忘记三连,如果有不恰当的地方也请提出来,下篇文章见。

    参考文章

    MeasureSpec讲解

    DividerItemDecoration.java

    Android 仿京东、拼多多商品分类页

  • 相关阅读:
    lv8 嵌入式开发-网络编程开发 18 广播与组播的实现
    FTP 基础 与 使用 Docker 搭建 Vsftpd 的 FTP 服务
    B站崩了,如果我们是那晚负责修复的开发人员
    CVE-2022-30190 Follina Office RCE分析【附自定义word钓鱼模板POC】
    腾讯云和阿里云4核8G云服务器多少钱一年和1个月费用对比
    C/C++常用函数
    FHQ-Treap 简介
    【React 源码】(五)React 应用的启动过程
    PAT A1150 Travelling Salesman Problem
    深入探讨安全验证:OAuth2.0、Cookie与Session、JWT令牌、SSO与开放授权平台设计
  • 原文地址:https://blog.csdn.net/Myfittinglife/article/details/134378553