使用RecyclerView开发TabView

文章详细介绍了如何使用RecyclerView配合自定义GestureLayout实现TabView效果,包括滑动布局、事件分发、惯性滚动、多item联动以及解决滑动冲突。同时,还涉及了Header布局、点击区域处理和滚动条的实现。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

github链接
demo代码
效果图
在这里插入图片描述
这个功能是使用RecyclerView开发的,需要解决下面这些问题

  1. 单个item滚动的问题:左边的view需要固定、手指松开之后,惯性的处理
  2. 滑动布局子View事件分发冲突的解决
  3. 多个item联合滚动滚动
  4. header
  5. 解决itemView与RecyclerView滑动冲突的问题
  6. 横向滚动时,显示和隐藏滚动条

带着上面想到的问题,逐一写demo,最后再把编写的代码糅合在一起,完成tab view。

第1个问题还是比较复杂的,也是核心问题,所以必须最先解决。
由于我以前写过左滑显示删除按钮的功能,所以滑动部分马上就想到在LinearLayout的基础上开发。而固定的功能反而是最简单的,直接在外部套一个LinearLayout,然后写一个View在最左边就行。
简单提了一下思路,接下来是功能的开发。

单个滑动布局
先实现滑动的功能,这个是最简单的,先看一下图片。

在这里插入图片描述
代码:
这里10个TextView的代码我就不提供了,没什么好说的,直接提供GestureLayout的代码。

class GestureLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    LinearLayout(context, attrs, defStyleAttr), View.OnTouchListener {
   

    private var scrollState = SCROLL_STATE_IDLE

    private var lastTouchX = 0
    // 当前滑动的距离
    private var scrollOffset = 0f
    // 最大可滑动的距离
    private var maxScrollOffset = 0f
    // 大于这个值才可以滑动
    private var touchSlop = 16

    init {
   
        orientation = HORIZONTAL
        setOnTouchListener(this)
    }

    override fun onAttachedToWindow() {
   
        super.onAttachedToWindow()
        val onGlobalLayoutListener = object : ViewTreeObserver.OnGlobalLayoutListener {
   
            override fun onGlobalLayout() {
   
                viewTreeObserver.removeOnGlobalLayoutListener(this)
                // 计算最大宽度
                var totalChildWith = 0
                for (i in 0 until childCount) {
   
                    totalChildWith += getChildAt(i).measuredWidth
                }
                // 可滑动的距离 = 最大宽度 - 当前View的宽度
                maxScrollOffset = (totalChildWith - width).toFloat()
            }
        }
        viewTreeObserver.addOnGlobalLayoutListener(onGlobalLayoutListener)
    }

    override fun onTouch(v: View?, ev: MotionEvent): Boolean {
   
        when (ev.action) {
   
            MotionEvent.ACTION_DOWN -> {
   
                lastTouchX = (ev.x + 0.5f).toInt()
            }
            MotionEvent.ACTION_MOVE -> {
   
                val x = (ev.x + 0.5f).toInt()
                val dx = lastTouchX - x
                if (scrollState != SCROLL_STATE_DRAGGING && Math.abs(dx) > touchSlop) {
   
                    scrollState = SCROLL_STATE_DRAGGING
                }
                if (scrollState == SCROLL_STATE_DRAGGING) {
   
                    lastTouchX = x
                    // 更新offset
                    updateScrollOffset(scrollOffset + dx)
                    scrollTo(scrollOffset.toInt(), 0)
                }
            }
            MotionEvent.ACTION_UP -> {
   
                // 回收资源
                recycler()
            }
        }
        return true
    }

    private fun recycler(){
   
        scrollState = SCROLL_STATE_IDLE
    }

    private fun updateScrollOffset(scrollOffset: Float) {
   
        this.scrollOffset = Math.min(maxScrollOffset, Math.max(0f, scrollOffset))
        // 这段代码可能有点绕,看下面这段代码就懂了
//        if (scrollOffset < 0f){
   
//            this.scrollOffset = 0f
//        }else if (scrollOffset > maxScrollOffset){
   
//            this.scrollOffset = scrollOffset
//        }else{
   
//            this.scrollOffset = scrollOffset
//        }
    }

    companion object {
   
        private const val SCROLL_STATE_IDLE = 0
        private const val SCROLL_STATE_DRAGGING = 1
    }
}

基础代码就是上面这些。可以看到,其实是很简单的,只需调用scrollTo,就可以了。该写的注释都已经写了,没啥好说的。
但很显然,简单的滑动是不够的,还需要做松开手指之后的惯性功能,这个就有点麻烦了。

在说如何实现这个功能之前,先来介绍2个需要用到的类。
VelocityTracker:顾名思义,速度追踪器,用来追踪速度的工具类。有3个在这里需要用到的方法:

  • addMovement:记录触摸事件,用于计算出up时的xVeloctiy和yVelocity。
  • computeCurrentVelocity(int, float):在调用getXVelocity之前,需要调用该方法进行计算。
  • getXVelocity:ACTION_UP时调用获取,再将该值传递给Scroller的fling方法,让Scroller计算出实际需要滚动的距离。

OverScroller:上面提到的Scroller,就是第2个类。而在OverScroller里面,有这样一句注释。

This class is a drop-in replacement for Scroller in most cases.

大多数情况下,可以直接使用OverScroller代替Scroller。所以这里直接使用OverScroller。
OverScroller的作用就是:是一个用于模拟滑动的工具类,用它来实现平滑移动时非常有用。注意,这个类只能辅助实现,不是直接实现。
几个需要用到的方法:

  • fling(startX, startY, veloctiyX, velocityY, minX, maxX, minY, maxY, overX, overY):用于惯性的处理。将起始的x/y值、滑动速度、x/y最小最大值传递给它之后,Scroller会计算出实际的x/y值,再让View滑动起来。
  • computeScrollOffset:用来计算当前的滑动位置。如果返回true,表示当前计算还没有完成,此时调用getCurrX/getCurrY可以获取到滑动的值。如果返回false则说明滑动已经完成,无需继续处理。该方法需要在View的computeScroll方法里面调用。
  • getCurrX/getCurrY:在调用computeScrollOffset之后,需要通过该方法获取实际滚动的值,再调用View的scrollTo/scrollBy方法,实现滚动。
  • abortAnimation:用来阻止Scroller滚动,一般在ACTION_DOWN中使用。

除了上面这两个类,还有2个View自带的方法需要解释。

  • invalidate/postInvalidate/postInvalidateOnAnimation:这3个方法都是刷新方法,都会让View调用draw方法,最后会调用computeScroll方法。这里的刷新我使用的是postInvalidateOnAnimation,因为这个方法刷新的次数更少,相对另外两个方法,性能更好。而这里对刷新的要求也不高,所以够用了。
  • computeScroll:在调用刷新方法之后,就会调用这个方法。在这个方法里面,需要调用Scroller的computeScrollOffset,如果返回true,就调用scrollTo/scrollBy方法滚动,再调用刷新方法,直到computeScrollOffset返回false。

总结一下流程:ACTION_UP -> VelocityTracker.addMovement -> VelocityTracker.computeCurrentVelocity -> VelocityTracker.getXVelocity -> Scroller.fling ->postInvalidateOnAnimation -> computeScroll ->Scroller.computeScrollOffset ->Scroller.getCurrX-> scrollTo -> postInvalidateOnAnimation
调用链路有点长,接下来看看代码实现吧,刚才已经写过的大部分代码不会写在下面。

private var touchSlop = 0

private val scroller = OverScroller(context)
private var velocityTracker: VelocityTracker? = null
private var minimumFlingVelocity = 0
private var maximumFlingVelocity = 0

init{
   
    // 借助ViewConfiguration获取下面这3个值
    val vc = ViewConfiguration.get(context)
    minimumFlingVelocity = vc.scaledMinimumFlingVelocity
    maximumFlingVelocity = vc.scaledMaximumFlingVelocity
    touchSlop = vc.scaledTouchSlop
}

override fun onTouch(v: View?, ev: MotionEvent): Boolean {
   
    // 初始化VelocityTracker
    initVelocityTrackerIfNoExits()
    // 每次都将event交给VelocityTracker分析
    velocityTracker?.addMovement(ev)
    when (ev.action) {
   
        MotionEvent.ACTION_DOWN -> {
   
            // 中断Scroller的滑动
            scroller.abortAnimation()
            lastTouchX = (ev.x + 0.5f).toInt()
        }
        // ACTION_MOVE的代码和上面的一样,就不贴出来了
        MotionEvent.ACTION_UP -> {
   
            if (scrollState == SCROLL_STATE_DRAGGING) {
   
                val velocityTracker = velocityTracker
                // 让VelocityTacker开始计算速度
                velocityTracker?.computeCurrentVelocity(1000, maximumFlingVelocity.toFloat())
                // 获取x的速度
                val xVelocity = velocityTracker?.xVelocity ?: 0f
                // 如果速度大于最小的速度,就开始fling
                if (Math.abs(xVelocity) > minimumFlingVelocity.toFloat()) {
   
                    scroller.fling(scrollOffset.toInt(), 0, -xVelocity.toInt(), 0, 0,    maxScrollOffset.toInt(), 0, 0, 0, 0)
                    postInvalidateOnAnimation()
                }
            }
            recycler()
        }
    }
}

private fun recycler(){
   
    recycleVelocityTracker()
    scrollState = SCROLL_STATE_IDLE
}

override fun computeScroll() {
   
    // super是空实现,想去掉也可以
    super.computeScroll()
    // 判断是否还在计算offset
    if (scroller.computeScrollOffset()) {
   
        val curX = Math.min(Math.max(scroller.currX.toFloat(), 0f), maxScrollOffset)
        if (curX != scrollOffset){
   
            scrollOffset = curX
        }
        scrollTo()
        if (scrollOffset == 0f || scrollOffset == maxScrollOffset){
   
            scroller.abortAnimation()
        }
    }
}

private fun initVelocityTrackerIfNoExits() {
   
    if (velocityTracker == null) {
   
        velocityTracker = VelocityTracker.obtain()
    }
}

private fun recycleVelocityTracker() {
   
    velocityTracker?.recycle()
    velocityTracker = null
}

private fun scrollTo(){
   
    scrollTo(scrollOffset, 0)
    postInvalidateOnAnimation()
}

效果图:
在这里插入图片描述


接下来先在左边加一个TextView实现左边固定的功能

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

    <TextView
        android:layout_width="@dimen/table_item_width"
        android:textColor="@color/black"
        android:text="stick"
        android:gravity="center"
        android:textSize="@dimen/table_item_text_size"
        android:layout_height="match_parent" />

    <GestureLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <include layout="@layout/merge_table_layout" />
    </GestureLayout>
</LinearLayout>

效果我就不贴出来了,一看就知道怎么回事。至于10个TextView使用include,这是因为后面的Header需要使用同一个layout,所以这样做可以避免编写重复代码。

接下来是子View事件分发的处理。这个View是一个ViewGroup,所以需要处理好touch事件。一些可以传递给子View的事件,就传递给子View,不能传递给子View的,就自己处理。
先来一个反例

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

    <TextView
        android:layout_width="@dimen/table_item_width"
        android:textColor="@color/black"
        android:text="stick"
        android:gravity="center"
        android:textSize="@dimen/table_item_text_size"
        android:layout_height=
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值