您现在的位置是:首页 >技术杂谈 >使用RecyclerView开发TabView网站首页技术杂谈

使用RecyclerView开发TabView

杜壁奇 2023-06-01 00:00:03
简介使用RecyclerView开发TabView

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="match_parent" />

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

        <LinearLayout
            android:id="@+id/click_area"
            android:layout_width="wrap_content"
            android:layout_height="match_parent">

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

java代码我就不贴了,只是给click_area设置了一个onClick,弹出toast,看一下效果图
在这里插入图片描述
可以看到,鼠标明明滑动了,但View却没有滑动,反而是触发了onClick,说明必须对某些事件进行处理。
而即使去掉onClick,也会出现问题。因为在onAttch方法里面,只是计算当前ViewGroup所有子View的width。此时,只有一个,计算出来的width是不正确的,导致maxScrollWidth不正确,最后没办法滑动。想要解决这个问题,就需要修改一点点代码。

override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    calculateMaxScrollOffset()
}

private fun calculateMaxScrollOffset(){
    // 用于计算的ViewGroup,可能是当前View,也可能是第一个子View
    val calculateViewGroup: ViewGroup
    // 如果childCount等于0,只会返回null,不用担心越界异常
    val firstChild = getChildAt(0)
    // 如果只有一个child,并且是ViewGroup才使用该View
    if (childCount == 1 && firstChild as? ViewGroup != null){
        calculateViewGroup = firstChild
    }else{
        calculateViewGroup = this
    }
    val onGlobalLayoutListener = object : ViewTreeObserver.OnGlobalLayoutListener {
        override fun onGlobalLayout() {
            calculateViewGroup.viewTreeObserver.removeOnGlobalLayoutListener(this)
            var totalChildWith = 0
            for (i in 0 until calculateViewGroup.childCount) {
                totalChildWith += calculateViewGroup.getChildAt(i).measuredWidth
            }
            maxScrollOffset = (totalChildWith - width).toFloat()
            // 只有有子Layout时,才需要重新设置当前layout和子layout的宽度
            if (calculateViewGroup != this@GestureLayout) {
                layout(left, top, left + totalChildWith, bottom)
                calculateViewGroup.layout(calculateViewGroup.left, calculateViewGroup.top, calculateViewGroup.left + totalChildWith, calculateViewGroup.bottom)
            }
        }
    }
    calculateViewGroup.viewTreeObserver.addOnGlobalLayoutListener(onGlobalLayoutListener)
}

通过上述的代码,就不用担心其他人在这个Layout下面,再加其他的Layout了。不过如果非要加几个layout,那就没办法了。
但我泼一下冷水,上面的代码看起来好像很好,但在RecyclerView里面使用,还是有问题,最后是使用其他方式解决这个问题。
这个问题就不再讨论了,开始着手解决事件分发的问题。因为不管有几个子View,都需要解决事件分发的问题。
先思考为什么设置了onClick就会使滑动失效?原因也很简单,设置了onClick,所以子View消费了所有事件,导致事件没办法传递到GestureLayout。解决方式也很简单,重写onInterceptTouchEvent方法,如果从ACTION_DOWN到ACTION_MOVE时,x坐标变化了,而且大于touchSlop,就认为滑动生效。此时,将scrollState设置为DRAGGING并返回true。这样就子View就会拿到ACTION_CANCEL,并且还会将move事件传递给GestureLayout的dispatchTouchEvent,最后传递到onTouch方法。只要到了onTouch方法,那代码就可以正常执行下去。

private var initialTouchX: Int = 0


override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    when(ev.action){
        MotionEvent.ACTION_DOWN -> {
            initialTouchX = (ev.x + 0.5f).toInt()
            lastTouchX = initialTouchX
            initOrResetVelocityTracker()
            velocityTracker?.addMovement(ev)
        }
        MotionEvent.ACTION_MOVE -> {
            val x = (ev.x + 0.5f).toInt()
            val dx = x - initialTouchX
            lastTouchX = x
            if (Math.abs(dx) > touchSlop) {
                initVelocityTrackerIfNoExits()
                velocityTracker?.addMovement(ev)
                scrollState = SCROLL_STATE_DRAGGING
            }
        }
        MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
            scrollState = SCROLL_STATE_IDLE
        }
    }
    return scrollState == SCROLL_STATE_DRAGGING
}

添加了这些代码之后,就可以正常滑动了,图片我就不提供了,自己试一下就知道了。

接下来是多个item联合滚动和header功能,由于功能类似,所以放在一起。
先确定一下思路。首先,想要多个联动滚动,肯定需要一个Manager来管理。能不能直接在Adapter里面的onBindViewHolder方法将View add到Manager里面,这样肯定不行,因为这样只有add没有remove,而且同一个item可能还会onBindViewHolder多次。然后我就想到了Adapter里面的onViewAttachedToWindow方法和onDetachedFromRecyclerView方法。分别对应View显示到界面和View从界面消失。最后经测试,这种方式确实可行,但每个Adapter都写同样的代码,有点烦,所以就尝试将代码放到View层的attch方法和detach方法,发现也可以,下面是简单的代码

override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    scrollManager?.also {
        it.addCandidate(this)}
    }
}
override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    scrollManager?.also {
        it.removeCandidate(this)
    }
}

接下来是设计ScrollManager。ScrollManager的作用是,统一管理所有的item。当某个item touch之后,将数据传递给Manager,让Manager统一调动所有的item。
首先,需要添加接口,通过接口将touch行为传递给ScrollManager。

interface OnScrolledChangedListener {
    // 在onInterceptTouchEvent里面调用,借助Scroller判断是否正在滚动,如果是,就返回true
    fun isScrollerFinished(): Boolean
    // 如果在onInteceptTouchEvent的ACTION_DOWN拦截了,就在onTouch组织Scroller滚动
    fun checkAndAbortAnimation()
    // ACTION_MOVE和ACTION_UP调用,更新offset
    fun onScrollChange(distanceX: Float)
    // ACTION_UP调用,让ScrollManager调用Scroller的fling
    fun onFling(velocityX: Int)
    // header的computeScroll调用。需要明确的是,不能让item去调用,因为这样做的话,会让Scroller同时调用computeScrollOffset,这样做是不合理的,也是会出问题的
    fun onComputeScroll()
    // ACTION_UP和ACTION_CANCEL调用,做一些收尾的工作
    fun draggingEnd()
}    

添加这个interface之后,就需要在GestureLayout里面添加相应的字段,让并在相应的时机调用对应的方法

// 注意,这里加了open。提供相应字段之后,再由子类自己去实现,所以改为可以继承
open class GestureLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    LinearLayout(context, attrs, defStyleAttr), View.OnTouchListener {
    
    var onScrolledChangeListener: OnScrolledChangedListener? = null
    
    // 此时,onAttachedToWindow的calculateMaxScrollOffset代码去去掉,暂时别考虑clickArea的问题
    // 这个方法直接去掉就行,不用重写,放在这里只做提醒
    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
    }
    
    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        when(ev.action){
            MotionEvent.ACTION_DOWN -> {
                ...
                scrollState = if (onScrolledChangeListener?.isScrollerFinished()
                        ?.not() == true
                ) SCROLL_STATE_DRAGGING else SCROLL_STATE_IDLE
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_UP -> {
                scrollState = SCROLL_STATE_IDLE
                onScrolledChangeListener?.draggingEnd()
            }
        }
    }
    
    override fun onTouch(v: View?, ev: MotionEvent): Boolean {
        ...
        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                // 注意,上面的onInterceptTouchEvent,有一句lastTouchX,这里可以去掉,因为已经放在onInterceptTouchEvent里面了
                onScrolledChangeListener?.checkAndAbortAnimation()
            }
            MotionEvent.ACTION_MOVE -> {
                ...
                if (scrollState == SCROLL_STATE_DRAGGING) {
                    lastTouchX = x
                    // 这里的scrollTo必须去掉,放在ScrollerManager里买实现
                    onScrolledChangeListener?.onScrollChange(dx.toFloat())
                }
            }
            MotionEvent.ACTION_UP -> {
                // ACTION_UP的部分代码也更新了
                if (scrollState == SCROLL_STATE_DRAGGING) {
                    val velocityTracker = velocityTracker
                    velocityTracker?.computeCurrentVelocity(1000, maximumFlingVelocity.toFloat())
                    val initialVelocity = velocityTracker?.xVelocity?.toInt() ?: 0
                    if (Math.abs(initialVelocity) > minimumFlingVelocity) {
                        onScrolledChangeListener?.onFling(-initialVelocity)
                    } else {
                        postInvalidateOnAnimation()
                    }
                }
                recycler()
            }
        }
    }
    
    private fun recycler() {
        scrollState = SCROLL_STATE_IDLE
        onScrolledChangeListener?.draggingEnd()
        recycleVelocityTracker()
    }
    
    override fun computeScroll() {
        if (this.childCount > 1) {
            onScrolledChangeListener?.onComputeScroll()
        }
    }
}

这样,GestureLayout就改造完成,接下来编写ScrollManager。然后再分别新增item能用的和header能用的两个Layout。这个Layout设置为open,就是为了这个。
ScrollManager

class ScrollManager(context: Context){
    private val minimumScrollOffset = 0.0f
    private var maximumScrollOffset = Float.MAX_VALUE
    private var scrollOffset = minimumScrollOffset
    private var scroller = OverScroller(context)
    // 存储要scroll的View
    private val scrollCandidateList = ArrayList<View>()

    // 这两个是滚动条要用的,现在不做开发,但留出相应的接口,最后会讲怎么实现滚动条
    private var scrollBar: HorizontalScrollBar? = null
    private var scrollBarOffset: Float = 0f

    // 记录是否正在fling,阻断一些不必要的代码
    private var isFling = false

    fun addCandidate(view: View) {
        if (scrollCandidateList.contains(view).not()) {
            scrollCandidateList.add(view)
            view.scrollTo(scrollOffset.toInt(), 0)
        }
    }

    fun removeCandidate(view: View) {
        scrollCandidateList.remove(view)
    }

    // 设置最大可滚动的距离,让header统一计算,然后调用该方法设置,不要让item去计算,否则就会设置很多次
    fun setMaxScrollOffset(maxOffset: Float) {
        maximumScrollOffset = maxOffset
    }

    fun scrollSpecialView(view: View) {
        view.scrollTo(scrollOffset.toInt(), 0)
    }

    fun clearViews() {
        scrollCandidateList.clear()
        clearScrollBar()
    }

    fun setScrollBar(bar: HorizontalScrollBar) {
        scrollBar = bar
    }

    fun clearScrollBar() {
        scrollBar = null
    }

    // OnScrolledChangedListener.isScrollerFinished
    fun isScrollerFinished(): Boolean {
        return scroller.isFinished
    }

    // OnScrolledChangedListener.checkAndAbortAnimation
    fun checkAndAbortAnimation() {
        if (!scroller.isFinished) {
            scroller.abortAnimation()
        }
    }

    // OnScrolledChangedListener.onScrollChange会掉孔这个方法计算offset,再调用下面的updateScroll滚动所有的View
    fun safeUpdateScrollPosition(distanceX: Float) {
        scrollOffset += distanceX
        scrollOffset = Math.min(Math.max(scrollOffset, minimumScrollOffset), maximumScrollOffset)
        scrollBarOffset = calculateScrollBarOffset()
    }

    fun updateScroll() {
        scrollCandidateList.forEach {
            it.scrollTo(scrollOffset.toInt(), 0)
            it.postInvalidateOnAnimation()
        }
        scrollBar?.updateScrollWeight(scrollBarOffset)
    }

    // OnScrolledChangedListener.onFling
    fun fling(velocityX: Int) {
        isFling = true
        scroller.fling(
            scrollOffset.toInt(), 0, velocityX, 0, 0, maximumScrollOffset.toInt(), 0, 0, 0, 0
        )
        updateScroll()
    }

    // OnScrolledChangedListener.onComputeScroll
    fun updateScrollForScroller() {
        // 返回true表示还在计算
        if (scroller.computeScrollOffset()) {
            val curX = getSafeUpdatePosition(scroller.currX)
            if (curX != scrollOffset.toInt()) {
                scrollOffset = curX.toFloat()
                scrollBarOffset = calculateScrollBarOffset()
            }
            updateScroll()
            // 到了屏幕边缘,也可以结束fling,所以做收尾操作
            if (scrollOffset == minimumScrollOffset || scrollOffset == maximumScrollOffset) {
                scroller.abortAnimation()
                isFling = false
                scrollBar?.startCountToHide()
            }
        } else {
            // 返回false表示计算完成,fling已经结束
            if (isFling) {
                isFling = false
                scrollBar?.startCountToHide()
            }
        }
    }

    // OnScrolledChangedListener.draggingEnd
    fun draggingEnd() {
        // 这个方法是UP或CANCEL时调用的
        // 此时,可能还在fling,所以不能隐藏。而如果没有再fling,就可以隐藏
        if (isFling.not()){
            scrollBar?.startCountToHide()
        }
    }

    private fun getSafeUpdatePosition(curX: Int): Int {
        return Math.min(Math.max(curX, minimumScrollOffset.toInt()), maximumScrollOffset.toInt())
    }

    private fun calculateScrollBarOffset(): Float {
        return scrollOffset / (maximumScrollOffset - minimumScrollOffset)
    }

    interface HorizontalScrollBar {
        // 更新滚动的位置
        fun updateScrollWeight(wieght: Float)
        // 滚动完成,隐藏滚动条
        fun startCountToHide()
    }
}

然后是item的GestureLayout,ItemGestureLayout

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

    private var scrollManager: ScrollManager? = null

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        scrollManager?.addCandidate(this)
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        scrollManager?.removeCandidate(this)
    }
    
    fun setScrollManager(scrollManager: ScrollManager){
        this.scrollManager = scrollManager
        onScrolleChangedListener = ItemGestureLayoutOnScrollChangedListener(scrollManager)
    }

    private class ItemGestureLayoutOnScrollChangedListener(scrollManager: ScrollManager): OnScrolledChangedListener{
        private val scrollManagerWeakRef = WeakReference(scrollManager)

        override fun isScrollerFinished(): Boolean {
            return scrollManagerWeakRef.get()?.isScrollerFinished() == true
        }

        override fun checkAndAbortAnimation() {
            scrollManagerWeakRef.get()?.checkAndAbortAnimation()
        }

        override fun onScrollChange(distanceX: Float) {
            scrollManagerWeakRef.get()?.apply {
                safeUpdateScrollPosition(distanceX)
                updateScroll()
            }
        }

        override fun onFling(velocityX: Int) {
            scrollManagerWeakRef.get()?.fling(velocityX)
        }

        // item空实现
        override fun onComputeScroll() {
        }

        override fun draggingEnd() {
            scrollManagerWeakRef.get()?.draggingEnd()
        }
    }
}

最后是header的GestureLayout,BaseHeaderGestureLayout

abstract class BaseHeaderGestureLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    FrameLayout(context, attrs, defStyleAttr) {

    private var inflated = AtomicBoolean(false)
    private var scrollManager: ScrollManager? = null

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        if (inflated.getAndSet(true).not()){
            inflate(context, getLayoutId(), this).also(::initLayout)
        }
        val scrollLayout = getScrollLayout() ?:return
        scrollManager?.also {
            it.addCandidate(scrollLayout)
        }
        scrollLayout.onScrolleChangedListener = HeaderGestureLayoutOnScrollChangedListener(scrollManager)
        val globalLayoutListener = object : ViewTreeObserver.OnGlobalLayoutListener{
            override fun onGlobalLayout() {
                scrollLayout.viewTreeObserver.removeOnGlobalLayoutListener(this)

                scrollLayout.apply {
                    var measureParent: ViewGroup = this
                    if (childCount == 1 && getChildAt(0) is ViewGroup){
                        measureParent = getChildAt(0) as ViewGroup
                    }
                    var sChildWidth = 0
                    for (i in 0 until measureParent.childCount){
                        sChildWidth += measureParent.getChildAt(i).measuredWidth
                    }
                    // 在header里面计算最大滚动offset
                    scrollManager?.setMaxScrollOffset((sChildWidth - measureParent.measuredWidth).toFloat())
                }
            }
        }
        scrollLayout.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        clearScrollLayoutAndManager()
    }

    fun setScrollManager(scrollManager: ScrollManager){
        this.scrollManager = scrollManager
        getScrollLayout()?.also {
            if (it.onScrolleChangedListener == null){
                it.onScrolleChangedListener = HeaderGestureLayoutOnScrollChangedListener(scrollManager)
            }
        }
    }

    private fun clearScrollLayoutAndManager(){
        getScrollLayout()?.onScrolleChangedListener = null
        scrollManager?.also {
            it.removeCandidate(this)
            scrollManager = null
        }
    }

    protected abstract fun getLayoutId(): Int
    protected abstract fun initLayout(view: View)
    protected abstract fun getScrollLayout(): GestureLayout?

    private class HeaderGestureLayoutOnScrollChangedListener(scrollManager: ScrollManager?): GestureLayout.OnScrolledChangedListener{
        private val scrollManagerWeakRef = WeakReference(scrollManager)

        override fun isScrollerFinished(): Boolean {
            return scrollManagerWeakRef.get()?.isScrollerFinished() == true
        }

        override fun checkAndAbortAnimation() {
            scrollManagerWeakRef.get()?.checkAndAbortAnimation()
        }

        override fun onScrollChange(distanceX: Float) {
            scrollManagerWeakRef.get()?.apply {
                safeUpdateScrollPosition(distanceX)
                updateScroll()
            }
        }

        override fun onFling(velocityX: Int) {
            scrollManagerWeakRef.get()?.fling(velocityX)
        }

        override fun onComputeScroll() {
            // compute统一在这里做,不要让item去做
            scrollManagerWeakRef.get()?.updateScrollForScroller()
        }

        override fun draggingEnd() {
            scrollManagerWeakRef.get()?.draggingEnd()
        }
    }
}

为什么item是直接继承GestureLayout,而Header继承FrameLayout并提供一个layoutId?因为考虑到item是用在RecyclerView里面,担心需要频繁调用inflate方法,而Header是直接放到layout文件里面,稳定性比较高,一般不会出什么问题。
此时,有人会想到,scroll的那些item和header完全一样,这个要怎么办?考虑到这个问题,我建议在开发时,这些view用一个layout文件编写。最外部的layout使用merge标签,这样就不会额外增加布局。然后分别在item和header的layout里面,通过include使用这个layout文件。从我上面提供的xml代码也可以看到,我在项目中,就是这样做的。这样就可以保证scrollView的一致性和可维护性。
TabAdapter

class TableAdapter :RecyclerView.Adapter<TableAdapter.ViewHolder>(){
    var scrollManager: ScrollManager? = null

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.table_item_test, parent, false)).also{vh ->
            scrollManager?.also {
                vh.scrollLayout.setScrollManager(it)
            }
        }
    }

    override fun getItemCount(): Int = 100

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.apply {
            scrollManager?.scrollSpecialView(scrollLayout)
            stickItemTv.text = "stickItem$position"
            item1Tv.text = "item$position-1"
            item2Tv.text = "item$position-2"
            ...
        }
    }

    class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView){
        val scrollLayout: ItemGestureLayout = itemView.findViewById(R.id.scroll_layout)
        val stickItemTv: TextView = itemView.findViewById(R.id.stick_item_tv)
        val item1Tv: TextView = itemView.findViewById(R.id.data1_tv)
        val item2Tv: TextView = itemView.findViewById(R.id.data2_tv)
        ...
    }
}

table_item_test

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="@dimen/table_item_height"
    android:background="@color/white"
    android:orientation="horizontal">

    <TextView
        android:id="@+id/stick_item_tv"
        android:layout_width="@dimen/table_item_width"
        android:layout_height="match_parent"
        android:gravity="center"
        android:textColor="@color/black"
        android:textSize="@dimen/table_item_text_size" />
    <View
        android:layout_width="1dp"
        android:layout_height="match_parent"
        android:background="#cccccc" />

    <ItemGestureLayout
        android:id="@+id/scroll_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <include layout="@layout/merge_table_layout" />
    </ItemGestureLayout>
</LinearLayout>

HeaderLayout

class HeaderLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    BaseHeaderGestureLayout(context, attrs, defStyleAttr) {

    private var scrollLayout: GestureLayout? = null

    override fun getLayoutId(): Int = R.layout.table_header_test

    override fun initLayout(view: View) {
        scrollLayout = view.findViewById(R.id.scroll_layout)
        val stickView: TextView = view.findViewById(R.id.stick_item_tv)
        val data1View: TextView = view.findViewById(R.id.data1_tv)
        val data2View: TextView = view.findViewById(R.id.data2_tv)
        ...

        stickView.text = "stick"
        data1View.text = "header1"
        data2View.text = "header2"
        ...
    }

    override fun getScrollLayout(): GestureLayout? = scrollLayout

}

table_header_test

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="@dimen/table_item_height"
    android:background="@color/white"
    android:orientation="horizontal">

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

    <View
        android:layout_width="1dp"
        android:layout_height="match_parent"
        android:background="#cccccc" />

    <GestureLayout
        android:id="@+id/scroll_layout"
        android:layout_width="wrap_content"
        android:layout_height="match_parent">

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

Activity

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(...)
    val scrollManager = ScrollManager(this)
    header_layout.setScrollManager(scrollManager)
    val adapter = TableAdapter()
    adapter.scrollManager = scrollManager
    recycler.adapter = adapter
    recycler.layoutManager = LinearLayoutManager(this,LinearLayoutManager.VERTICAL, false)
}

activity_layout

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <HeaderLayout
        android:id="@+id/header_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>

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

接下来解决滑动冲突的问题。为什么这个问题要憋到这里才说这么解决?因为只有将GestureLayout放到RecyclerView里面,这个问题才会特别明显,明显到影响正常使用。如果在上面编写GestureLayout就顺便加入解决的代码,那就对这个问题没什么感知。
先看一看有问题的效果图
在这里插入图片描述
可以看到,每次水平滑动时,只要有垂直滑动,就会打断水平滑动,体验起来非常糟心。解决方式也很简单,只要在GestureLayout加上requestDisallowInterceptTouchEvent就行,让RecyclerView不要拦截滑动事件。
GestureLayout

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    when(ev.action){
        ...
        MotionEvent.ACTION_MOVE -> {
            val x = (ev.x + 0.5f).toInt()
            val dx = x - initialTouchX
            lastTouchX = x
            if (Math.abs(dx) > touchSlop) {
                initVelocityTrackerIfNoExits()
                velocityTracker?.addMovement(ev)
                scrollState = SCROLL_STATE_DRAGGING
                // 新增的代码
                parent.requestDisallowInterceptTouchEvent(true)
            }
        }
        ...
    }
}

override fun onTouch(v: View?, ev: MotionEvent): Boolean {
   when(ev.action){
        ...
        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
                // 新增的代码
                parent.requestDisallowInterceptTouchEvent(true)
            }
            if (scrollState == SCROLL_STATE_DRAGGING) {
                lastTouchX = x
                onScrolleChangedListener?.onScrollChange(dx.toFloat())
            }
        }
       ...
   } 
}

也就加了2行代码,所以我就不贴图了,自己试一下就知道了。
接下来解决一下clickArea的问题,不然这个View没办法做点击时间,所有事件都被onTouch方法消费了。而如果在onTouch里面处理onClick,又会让这个方法的代码变得比较复杂。
ItemGestureLayout

private var clickArea: View? = null

// 为什么这里要判断clickArea为空才add,因为如果click不为空,add的是clickArea,不是当前View
override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    if (clickArea == null) {
        scrollManager?.addCandidate(this)
    }
}

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    if (clickArea == null) {
        scrollManager?.removeCandidate(this)
    }
}

// setScrollManager方法可以删掉了,换成下面这个
fun setScrollManagerAndClickArea(scrollManager: ScrollManager, clickArea: View?) {
    this.scrollManager = scrollManager
    this.clickArea = clickArea
    onScrolleChangedListener = ItemGestureLayoutOnScrollChangedListener(scrollManager)
    clickArea?.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
        override fun onViewAttachedToWindow(v: View) {
            // 当clickArea attach时,add到ScrollManager
            scrollManager.also {
                it.addCandidate(v)
            }
        }

        override fun onViewDetachedFromWindow(v: View) {
            // 当clickArea detach时,从ScrollManager remove
            scrollManager.also {
                it.removeCandidate(v)
            }
        }
    })
}

table_header_test的include代码外面,再套上一个LinearLayout,代码我就不提供了,看看adapter的代码。
TabAdapter

class TableAdapter :RecyclerView.Adapter<TableAdapter.ViewHolder>(){
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.table_item_test, parent, false)).also{vh ->
        scrollManager?.also {
            // 第2个参数传入clickArea
            vh.scrollLayout.setScrollManagerAndClickArea(it, vh.clickArea)
        }
    }
    
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.apply {
            // 这里记得改为clickArea,不要使用scrollLayout
            scrollManager?.scrollSpecialView(clickArea)
            clickArea.setOnClickListener {
                Toast.makeText(itemView.context, "toast", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

    class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView){
        ...
        val clickArea: View = itemView.findViewById(R.id.click_area)
        ,,,
    }
}

好了,剩下滚动条,上面在编写ScrollManager时,我就留出了相应的接口,所以只要实现接口的功能就可以了。
滚动条我是使用RecyclerView的ItemDecoration实现,使用ItemDecoration的drawOver方法,就可以将滚动条绘制在RecyclerView的上面。
TabScrollBar

class TabScrollBar(private val recyclerView: RecyclerView) : RecyclerView.ItemDecoration(), ScrollManager.HorizontalScrollBar {
    private var barWidth = 150f
    private var barHeight = 10f
    private var barMarginHorizontal = 20f
    private var barMarginBottom = 20f
    private var barMarginFirstColumn = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 100f, recyclerView.context.resources.displayMetrics)

    private var scrollPosition = 0f
    private var isShowing = false
    private var barShowingTime = 500L

    private val rect = RectF()
    private val paint = Paint().also {
        it.isAntiAlias = true
        it.style = Paint.Style.FILL
        it.color = 0xffcccccc.toInt()
    }

    private val handler = Handler(Looper.getMainLooper())
    private val dismissAction = Runnable {
        isShowing = false
        // 必须通过RecyclerView的invalidate方法才能隐藏ScrollBar
        // 原因是:ItemDecoration是在RecyclerView的draw方法绘制的,需要让RecyclerView刷新一次界面,才不会将不想出现的内容绘制出来
        recyclerView.postInvalidateOnAnimation()
    }

    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)
        // 如果不是showing,就return
        if (isShowing.not()) {
            return
        }
        val width = parent.width
        val height = parent.height
        val offset = (width - 2 * barMarginHorizontal - barMarginFirstColumn.toInt() - barWidth) * scrollPosition

        val left = barMarginHorizontal + barMarginFirstColumn.toInt() + offset.toInt()
        val right = left + barWidth
        val bottom = height - barMarginBottom
        val top = bottom - barHeight

        rect.left = left
        rect.top = top
        rect.right = right
        rect.bottom = bottom

        val rx = barHeight / 2
        val ry = barHeight / 2

        c.drawRoundRect(rect, rx, ry, paint)
    }

    override fun updateScrollWeight(wieght: Float) {
        scrollPosition = wieght
        isShowing = true
        // 这个invalidate是我自己写的,最终是调用RecyclerView.invalidateItemDecorations刷新ItemDecoration
        // 通过这个方法,就可以实时更新scrollPosition
        invalidate()
    }

    override fun startCountToHide() {
        handler.removeCallbacks(dismissAction)
        handler.postDelayed(dismissAction, barShowingTime)
    }

    fun setBarWidth(barWidth: Float) {
        if (this.barWidth == barWidth) {
            return
        }
        this.barWidth = barWidth
        invalidate()
    }

    fun setBarHeight(barHeight: Float) {
        if (this.barHeight == barHeight) {
            return
        }
        this.barHeight = barHeight
        invalidate()
    }

    fun setBarMarginHorizontal(barMarginHorizontal: Float) {
        if (this.barMarginHorizontal == barMarginHorizontal) {
            return
        }
        this.barMarginHorizontal = barMarginHorizontal
        invalidate()
    }

    fun setBarMarginBottom(barMarginBottom: Float) {
        if (this.barMarginBottom == barMarginBottom) {
            return
        }
        this.barMarginBottom = barMarginBottom
        invalidate()
    }

    fun setBarMarginFirstColum(barMarginFirstColumn: Float) {
        if (this.barMarginFirstColumn == barMarginFirstColumn) {
            return
        }
        this.barMarginFirstColumn = barMarginFirstColumn
        invalidate()
    }

    fun setBarShowingTime(barShowingTime: Long){
        this.barShowingTime = barShowingTime
    }

    fun setBarColor(color: Int) {
        if (paint.color == color){
            return
        }
        paint.color = color
        invalidate()
    }

    private fun invalidate() {
        recyclerView.invalidateItemDecorations()
    }
}

Activity

val scrollBar = TabScrollBar(recycler)
recycler.addItemDecoration(scrollBar)
scrollManager.setScrollBar(scrollBar)

效果图:
在这里插入图片描述
可以看到,效果已经和最上面的图片一样了,实现方式还是比较简单的。

好了通过上面这么多代码,就做出TabView。再提一下我在实际开发中关于TabView的开发规范吧。
Adapter layout文件命名:我这边使用的是table_item_xxx,这样别人一看,就知道这是一个和tab item有关的layout
header文件命名:table_header_xxx,和上面一样。
而item和header的layout文件里面,stick的TextView用一个名称的textSize和textColor等参数,这样就可以保证UI改了,我们这边修改比较方便。而GestureLayout里面,使用include标签引入layout文件。item和header使用同一个layout文件,这样就可以降低维护成本。而这个layout文件的顶级标签是merge,所以在明明时,我用的是:merge_table_xxx。这样别人看到之后,就知道这是一个和table有关的merge layout。当然了,table_merge_xxx也可以,看个人或团队的具体情况。

最后再总结一下:
GestureLaout:借助VelocityTacker和Scroller实现松开手指后惯性滑动的功能。Scroller本身不具备滑动的功能,最终实现还是需要用到View的scrollTo/ScrollBy方法。在这个过程中,需要借助View的computeScroll方法来一直调用scrollTo/scrollBy方法让View滚动起来。
ScrollManager:到了RecyclerView层面,就需要用一个Manager来控制所有item。所以GestureLayout就通过interface将touch操作暴露出去,让ScrollManager来统一调用所有的item进行滚动。而ScrollManager是怎么添加和移除item?是使用View的attch方法和detach方法。当View attch时,添加到Manager里面。detach时,从Manager移除。这样就保证了Manager里面不会存在多余的item。
header:直接在RecylerView上面放一个header layout,header layout里面,使用的也是GestureLayout,并add到Manager统一管理。这样当item滚动时,也能带着header一起滚动。
拦截RecyclerView的事件:如果不对RecyclerView的事件进行拦截,在水平滑动时,进行垂直滑动就会打断item滑动的功能。这种体验是非常糟糕的, 因为在实际使用时,很容易就触发这个。不过解决方式也很简单,只需要滑动时,调用requestDisallowInterceptTouchEvent方法让RecyclerView不要拦截touch事件即可。
滚动条:使用RecyclerView.ItemDecoration的drawOver方法,在RecyclerView上面绘制内容即可。如果需要更新滚动条的位置,就使用RecyclerView.invalidateItemDecorations方法,还是比较方便的。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。