一、实现效果

Android屏幕显示范围有限,在数据可视化需求中经常要使用这样的表格:横向纵向都能滑动,左侧栏目固定,右边可以整体上下左右滑动。例如股票、证券、课程表、值日表、Excel等等

主要就是分为 标题内容 两部分,其中内容部分一般都是嵌套RecyclerView,使得标题和内容左右横向滑动时是一起滑动的,且不能错位。

效果示例如下:
在这里插入图片描述

二、实现思路

RecyclerView有一个回收和复用机制,ViewHolder可重复回收利用,如下图第三行从屏幕消失时会重新移到第一行复用,所以我们在适配器中使用if语句改变布局,必须要有else处理默认布局,否则复用时还是改变过的布局。
在这里插入图片描述
具体可以自行去分析源码,对于实现复杂效果非常有帮助。
RecyclerView回收复用机制

该效果可以有多种实现方式,根据不同的业务需求去选定:

1、 (标题) HorizontalScrollView、(内容) ListView 【(左)TextView+(右)HorizontalScrollView 】
2、 (标题) HorizontalScrollView、(内容) RecyclerView【(左)TextView+(右)HorizontalScrollView 】 参考文章:安卓使用RecyclerView+HorizontalScrollView 实现Item整体横向滑动
3、(标题) RecyclerView、(内容) RecyclerView【(左)TextView+(右)RecyclerView】

这几种思路大同小异,都是嵌套滑动控件,都能很好地实现效果。第二种需要重写HorizontalScrollView有些麻烦,需要专门去处理HorizontalScrollView 嵌套横向数据过多而导致的性能问题,第三种简洁且性能稍微好点,也是接下来要介绍的。

布局架构:嵌套去实现上下左右滑动
在这里插入图片描述
难点则是:

标题的RecyclerView与下面内容RecyclerView的Item中RecyclerView的同步横向滚动

三、具体实现

三个适配器就能实现:TableTopAdapter和TableRightScrollAdapter左右联动

标题:TableTopAdapter
内容:TableContentAdapter
内容item中的 RecyclerView:TableRightScrollAdapter

下面布局中出现的1dp的view是边框线。

  1. 表格布局xml:
			<LinearLayout
                android:id="@+id/ll_top_root"
                android:layout_width="match_parent"
                android:layout_height="70dp"
                android:background="@android:color/white"
                android:orientation="horizontal"
                android:visibility="visible">

                <androidx.constraintlayout.widget.ConstraintLayout
                    android:layout_width="119dp"
                    android:layout_height="match_parent">

                    <TextView
                        android:id="@+id/tv_left_title_top"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="日期"
                        android:textSize="15sp"
                        app:layout_constraintHorizontal_bias="0.8"
                        app:layout_constraintVertical_bias="0.3"
                        app:layout_constraintBottom_toBottomOf="parent"
                        app:layout_constraintEnd_toEndOf="parent"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toTopOf="parent" />

                    <TextView
                        android:id="@+id/tv_left_title_bottom"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="区域"
                        android:textSize="15sp"
                        app:layout_constraintHorizontal_bias="0.2"
                        app:layout_constraintVertical_bias="0.7"
                        app:layout_constraintBottom_toBottomOf="parent"
                        app:layout_constraintEnd_toEndOf="parent"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toTopOf="parent" />

                    <ImageView
                        android:id="@+id/tv_left_title_line"
                        android:layout_width="150dp"
                        android:layout_height="1dp"
                        android:background="#b8b8b8"
                        app:layout_constraintBottom_toBottomOf="parent"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toTopOf="parent"
                        app:layout_constraintEnd_toEndOf="parent"
                        app:layout_constraintVertical_bias="0.52" />

                </androidx.constraintlayout.widget.ConstraintLayout>
                <View
                    android:layout_width="1dp"
                    android:layout_height="match_parent"
                    android:background="#b8b8b8"/>

                <RelativeLayout
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:orientation="vertical">
                    <androidx.recyclerview.widget.RecyclerView
                        android:id="@+id/recycler_title"
                        android:layout_width="match_parent"
                        android:layout_height="match_parent"
                        android:overScrollMode="never"
                        android:scrollbars="none">
                    </androidx.recyclerview.widget.RecyclerView>
                </RelativeLayout>

            </LinearLayout>
            <View
                android:id="@+id/ll_top_root_line"
                android:layout_width="match_parent"
                android:layout_height="1dp"
                android:background="#b8b8b8"/>
            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/recycler_content"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_weight="1">

在这里插入图片描述
标题固定的单元格中间的线做了一个旋转,达到倾斜分割的显示。

  1. 标题适配器 TableTopAdapter 和 内容item中的右侧适配器 TableRightScrollAdapter:这里比较简单,这就是BaseQuickAdapter的快捷便利,它的使用之前有写过文章:BaseQuickAdapter使用(RecyclerView万能适配器)
class TableTopAdapter: BaseQuickAdapter<String, BaseViewHolder>(R.layout.item_table_scroll)  {
    override fun convert(holder: BaseViewHolder, item: String) {
        holder.setText(R.id.tv_scroll,item)
    }
}

class TableRightScrollAdapter: BaseQuickAdapter<String, BaseViewHolder>(R.layout.item_table_scroll)  {
    override fun convert(holder: BaseViewHolder, item: String) {
        holder.setText(R.id.tv_scroll,item)
    }
}
  1. 单元格布局 item_table_scroll.xml:标题的item和内容item中的右侧item都是使用它,所以叫单元格
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="120dp"
    android:layout_height="70dp"
    android:orientation="horizontal">

    <TextView
        android:gravity="center"
        android:id="@+id/tv_scroll"
        android:layout_width="119dp"
        android:maxLines="3"
        android:ellipsize="end"
        android:layout_height="match_parent"/>
    <View
        android:layout_width="1dp"
        android:layout_height="match_parent"
        android:background="#b8b8b8"/>
</LinearLayout>

内容右侧每一个单元格的宽度和高度都要和标题的单元格一样,否则错位,例如宽度都是119dp的文章加上1dp的边框线

  1. 内容布局 item_table_content.xml:整体上下滑动,其中左侧固定,右侧嵌套左右滑动
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:background="@android:color/white"
    android:layout_height="70dp"
    android:orientation="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="69dp"
        android:orientation="horizontal">
        <LinearLayout
            android:layout_width="120dp"
            android:orientation="horizontal"
            android:layout_height="match_parent">

            <TextView
                android:id="@+id/tv_left_title"
                android:layout_width="119dp"
                android:layout_height="match_parent"
                android:text=""
                android:gravity="center"/>
            <View
                android:layout_width="1dp"
                android:layout_height="match_parent"
                android:background="#b8b8b8"/>
        </LinearLayout>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv_item_right"
            android:layout_width="match_parent"
            android:layout_height="69dp"
            android:overScrollMode="never"
            android:scrollbars="none">

        </androidx.recyclerview.widget.RecyclerView>
    </LinearLayout>
    <View
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:background="#b8b8b8"/>

</LinearLayout>
  1. 内容适配器 TableContentAdapter :关键联动代码,整体来看联动的就是标题的一个recycleview和内容里面的多个recycleview
//传入标题RecyclerView:recycler_title
class TableContentAdapter(val headRecycler:RecyclerView): BaseQuickAdapter<TableContentBean, BaseViewHolder>(R.layout.item_table_content)  {
    private var firstPos = -1
    private var firstOffset = -1
    
    //保存所有 recyclerView
    private val observerList= hashSetOf<RecyclerView>()
    
    //初始化
    init {
        // 1. 处理头部的一个recycleview联动
        initRecyclerView(headRecycler)
    }
    override fun convert(holder: BaseViewHolder, item: TableContentBean) {
        holder.setText(R.id.tv_left_title,item.leftTitle)
        
        //右边滑动部分
        holder.getView<RecyclerView>(R.id.rv_item_right).layoutManager = LinearLayoutManager(context,LinearLayoutManager.HORIZONTAL,false)
        holder.getView<RecyclerView>(R.id.rv_item_right).setHasFixedSize(true)
        val rightScrollAdapter = TableRightScrollAdapter()
        rightScrollAdapter.setNewInstance(item.rightDates)
        holder.getView<RecyclerView>(R.id.rv_item_right).adapter = rightScrollAdapter
        rightScrollAdapter.notifyDataSetChanged()

        // 2. 处理内容的多个recycleview联动
        initRecyclerView(holder.getView<RecyclerView>(R.id.rv_item_right))
    }

    //所有recycleview联动
    @SuppressLint("ClickableViewAccessibility")
    fun initRecyclerView(recyclerView: RecyclerView) {
        recyclerView.setHasFixedSize(true)
        //获取每一个recycleview的layoutManager
        val layoutManager: LinearLayoutManager = recyclerView.layoutManager as LinearLayoutManager
        
        //上拉加载更多的时候  保证新加载出来的item 跟已经滑动的item位置保持一致,原来是>0/>0
        if (firstPos >= 0 && firstOffset >= 0) {
        	// 通过移动layoutManager来实现列表滑动,让新加载的item条目保持跟已经滑动的recycleview位置保持一致
            layoutManager.scrollToPositionWithOffset(firstPos + 1, firstOffset)
        }
        
        // 添加所有的 recyclerView
        observerList.add(recyclerView)
        
        //当触摸条目的时候 停止滑动
        recyclerView.setOnTouchListener { view, motionEvent ->
            when (motionEvent.action) {
                MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> for (rv in observerList) {
                    rv.stopScroll()
                }
            }
            false
        }
        
        //当前recycleview的滑动监听。当前滑动,其他所有recycleview都要滑动
        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                val linearLayoutManager: LinearLayoutManager =
                    recyclerView.layoutManager as LinearLayoutManager
                //获取第一个item的位置
                val currentFirstPos: Int = linearLayoutManager.findFirstVisibleItemPosition()
                //获取第一个item的View
                val firstVisibleItem= linearLayoutManager.getChildAt(0)
                if (firstVisibleItem != null) {
                    //获取第一个item的偏移量
                    val firstRight: Int = linearLayoutManager.getDecoratedRight(firstVisibleItem)
                    //遍历其它的所有的recycleview
                    for (rv in observerList) {
                        if (recyclerView !== rv) {
                            val mLayoutManager= rv.layoutManager as? LinearLayoutManager
                            mLayoutManager?.let{
                                firstPos = currentFirstPos
                                firstOffset = firstRight
                                //通过当前显示item的位置和偏移量的位置来同步其它item的移动距离
                                mLayoutManager.scrollToPositionWithOffset(firstPos + 1, firstRight)
                            }
                        }
                    }
                }
            }

            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                super.onScrollStateChanged(recyclerView, newState)
            }
        })
    }
}

//内容的数据格式
data class TableContentBean (
    val leftTitle: String,
    val rightDates:ArrayList<String>
    ){
}

layoutManager.scrollToPositionWithOffset(firstPos + 1, firstRight)

移动layoutManager来实现条目滑动,通过子条目的横向滑动来联动其它条目同步滑动,从而解决整体滑动的问题,避免嵌套HorizontalScrollView 从而提高性能问题。

  1. Activity中使用:
		private val titleList by lazy{ arrayListOf("星期一","星期二","星期三","星期四","星期五") }
		private val contentList by lazy{ arrayListOf(
		TableContentBean("区域1",arrayListOf("1","2","3","4","5")),
		TableContentBean("区域2",arrayListOf("1","2","3","4","5")),
		) }
		
		//处理标题部分
        recycler_title.layoutManager = LinearLayoutManager(this,LinearLayoutManager.HORIZONTAL,false)
        val topTabAdapter = TableTopAdapter()
        recycler_title.adapter = topTabAdapter
        topTabAdapter.setNewInstance(topTabs)
        topTabAdapter.notifyDataSetChanged()
        tv_left_title_line.rotation=30f //旋转

        //处理内容部分
        recycler_content.layoutManager = LinearLayoutManager(this)
        recycler_content.setHasFixedSize(true)
        val contentAdapter = TableContentAdapter(recycler_title)
        recycler_content.adapter = contentAdapter
        contentAdapter.setNewInstance(contentList)
        contentAdapter.notifyDataSetChanged()

总结

五月份也即将结束了,回想去年五月,五一五四难得的假期,抓紧时间在图书馆学习,现在看待学习和当初看待学习的感觉也已截然不同,是非成败转头空。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐