仿京东拼多多商品分类页-(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项滑动到顶部即可

kotlin 复制代码
leftAdapter.setLeftClickListener(object : LeftAdapter.LeftClickListener {
    override fun onItemClick(position: Int) {
        var layoutManager = binding.rightRcy.layoutManager as LinearLayoutManager

        //将position该位置的itemView移动到第一项
        layoutManager.scrollToPositionWithOffset(position, 0)
    }
})

3、右侧列表适配器

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

  • 当滑动时,实时获取右侧第一个可见项所在的位置position,同时将左侧RecyclerView的position项选中
  • 当滑动到底部且无法下滑时,将左侧RecyclerView的最后一项选中
  • 后续可以增加:如果左侧选中项位置太低,将其滑动到上方来的操作
kotlin 复制代码
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)
    }
}
})

4、头部及悬浮头部绘制

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

kotlin 复制代码
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
    }
}

这里我们首先需要加载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无效
  • 这里的头部布局如下,建议在最外层套一层

    xml 复制代码
    <?xml version="1.0" encoding="utf-8"?>
    <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>

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

    xml 复制代码
    <?xml version="1.0" encoding="utf-8"?>
    <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>
  • 宽高设置

    kotlin 复制代码
    val width = parent.layoutManager?.width?:0
    headTitleView?.measure(
        View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
        View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
    )

    宽度:

    因为我们头部布局的父容器为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()

kotlin 复制代码
/**
 * 绘制头部
 */
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()
        }
    }
}

具体的头部绘制的位置,可参考前两篇文章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()

kotlin 复制代码
 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()
        }
    }

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

具体的绘制和onDraw()方法中的绘制流程一致。

总结

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

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

参考文章

MeasureSpec讲解

DividerItemDecoration.java

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

相关推荐
itachi-uchiha1 天前
awk处理xml文件&&封装集合变量和调用
xml·shell·awk
武子康4 天前
Java-39 深入浅出 Spring - AOP切面增强 核心概念 通知类型 XML+注解方式 附代码
xml·java·大数据·开发语言·后端·spring
Ll13045252986 天前
基于 COM 的 XML 解析技术(MSXML) 的总结
xml
在代码的海洋中寻找亚特兰蒂斯6 天前
AJAX对于XML和JSON的处理
xml·ajax·json
BinField7 天前
ToolsSet之:XML工具
xml·windows·microsoft
SEO-狼术8 天前
Connect Directly to Oracle XML Data
xml·数据库·oracle
YSoup8 天前
2025年目前最新版本Android Studio自定义xml预览的屏幕分辨率
android·xml·android studio
abcnull9 天前
mybatis的mapper对应的xml写法
xml·sql·spring·mybatis·mapper
Blue桃之夭夭9 天前
HTML、XML、JSON 是什么?有什么区别?又是做什么的?
xml·html·json
小于村9 天前
pom.xml 文件中配置你项目中的外部 jar 包打包方式
xml·java·jar