> 文章列表 > 使用RecyclerView开发TabView

使用RecyclerView开发TabView

使用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在最左边就行。
简单提了一下思路,接下来是功能的开发。

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

使用RecyclerView开发TabView
代码:
这里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_IDLEprivate var lastTouchX = 0// 当前滑动的距离private var scrollOffset = 0f// 最大可滑动的距离private var maxScrollOffset = 0f// 大于这个值才可以滑动private var touchSlop = 16init {orientation = HORIZONTALsetOnTouchListener(this)}override fun onAttachedToWindow() {super.onAttachedToWindow()val onGlobalLayoutListener = object : ViewTreeObserver.OnGlobalLayoutListener {override fun onGlobalLayout() {viewTreeObserver.removeOnGlobalLayoutListener(this)// 计算最大宽度var totalChildWith = 0for (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 - xif (scrollState != SCROLL_STATE_DRAGGING && Math.abs(dx) > touchSlop) {scrollState = SCROLL_STATE_DRAGGING}if (scrollState == SCROLL_STATE_DRAGGING) {lastTouchX = x// 更新offsetupdateScrollOffset(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 = 0private 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 = 0private val scroller = OverScroller(context)
private var velocityTracker: VelocityTracker? = null
private var minimumFlingVelocity = 0
private var maximumFlingVelocity = 0init{// 借助ViewConfiguration获取下面这3个值val vc = ViewConfiguration.get(context)minimumFlingVelocity = vc.scaledMinimumFlingVelocitymaximumFlingVelocity = vc.scaledMaximumFlingVelocitytouchSlop = vc.scaledTouchSlop
}override fun onTouch(v: View?, ev: MotionEvent): Boolean {// 初始化VelocityTrackerinitVelocityTrackerIfNoExits()// 每次都将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// 如果速度大于最小的速度,就开始flingif (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()// 判断是否还在计算offsetif (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实现左边固定的功能

<LinearLayoutandroid:layout_width="match_parent"android:orientation="horizontal"android:layout_height="50dp"><TextViewandroid: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" /><GestureLayoutandroid: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的,就自己处理。
先来一个反例

<LinearLayoutandroid:layout_width="match_parent"android:orientation="horizontal"android:layout_height="50dp"><TextViewandroid: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" /><GestureLayoutandroid:layout_width="match_parent"android:layout_height="match_parent"><LinearLayoutandroid: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,看一下效果图
使用RecyclerView开发TabView
可以看到,鼠标明明滑动了,但View却没有滑动,反而是触发了onClick,说明必须对某些事件进行处理。
而即使去掉onClick,也会出现问题。因为在onAttch方法里面,只是计算当前ViewGroup所有子View的width。此时,只有一个,计算出来的width是不正确的,导致maxScrollWidth不正确,最后没办法滑动。想要解决这个问题,就需要修改一点点代码。

override fun onAttachedToWindow() {super.onAttachedToWindow()calculateMaxScrollOffset()
}private fun calculateMaxScrollOffset(){// 用于计算的ViewGroup,可能是当前View,也可能是第一个子Viewval calculateViewGroup: ViewGroup// 如果childCount等于0,只会返回null,不用担心越界异常val firstChild = getChildAt(0)// 如果只有一个child,并且是ViewGroup才使用该Viewif (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 = 0for (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 = 0override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {when(ev.action){MotionEvent.ACTION_DOWN -> {initialTouchX = (ev.x + 0.5f).toInt()lastTouchX = initialTouchXinitOrResetVelocityTracker()velocityTracker?.addMovement(ev)}MotionEvent.ACTION_MOVE -> {val x = (ev.x + 0.5f).toInt()val dx = x - initialTouchXlastTouchX = xif (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判断是否正在滚动,如果是,就返回truefun isScrollerFinished(): Boolean// 如果在onInteceptTouchEvent的ACTION_DOWN拦截了,就在onTouch组织Scroller滚动fun checkAndAbortAnimation()// ACTION_MOVE和ACTION_UP调用,更新offsetfun onScrollChange(distanceX: Float)// ACTION_UP调用,让ScrollManager调用Scroller的flingfun 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_IDLEonScrolledChangeListener?.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 = velocityTrackervelocityTracker?.computeCurrentVelocity(1000, maximumFlingVelocity.toFloat())val initialVelocity = velocityTracker?.xVelocity?.toInt() ?: 0if (Math.abs(initialVelocity) > minimumFlingVelocity) {onScrolledChangeListener?.onFling(-initialVelocity)} else {postInvalidateOnAnimation()}}recycler()}}}private fun recycler() {scrollState = SCROLL_STATE_IDLEonScrolledChangeListener?.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.0fprivate var maximumScrollOffset = Float.MAX_VALUEprivate var scrollOffset = minimumScrollOffsetprivate var scroller = OverScroller(context)// 存储要scroll的Viewprivate val scrollCandidateList = ArrayList<View>()// 这两个是滚动条要用的,现在不做开发,但留出相应的接口,最后会讲怎么实现滚动条private var scrollBar: HorizontalScrollBar? = nullprivate var scrollBarOffset: Float = 0f// 记录是否正在fling,阻断一些不必要的代码private var isFling = falsefun 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.isScrollerFinishedfun isScrollerFinished(): Boolean {return scroller.isFinished}// OnScrolledChangedListener.checkAndAbortAnimationfun checkAndAbortAnimation() {if (!scroller.isFinished) {scroller.abortAnimation()}}// OnScrolledChangedListener.onScrollChange会掉孔这个方法计算offset,再调用下面的updateScroll滚动所有的Viewfun safeUpdateScrollPosition(distanceX: Float) {scrollOffset += distanceXscrollOffset = Math.min(Math.max(scrollOffset, minimumScrollOffset), maximumScrollOffset)scrollBarOffset = calculateScrollBarOffset()}fun updateScroll() {scrollCandidateList.forEach {it.scrollTo(scrollOffset.toInt(), 0)it.postInvalidateOnAnimation()}scrollBar?.updateScrollWeight(scrollBarOffset)}// OnScrolledChangedListener.onFlingfun fling(velocityX: Int) {isFling = truescroller.fling(scrollOffset.toInt(), 0, velocityX, 0, 0, maximumScrollOffset.toInt(), 0, 0, 0, 0)updateScroll()}// OnScrolledChangedListener.onComputeScrollfun 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 = falsescrollBar?.startCountToHide()}} else {// 返回false表示计算完成,fling已经结束if (isFling) {isFling = falsescrollBar?.startCountToHide()}}}// OnScrolledChangedListener.draggingEndfun 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? = nulloverride fun onAttachedToWindow() {super.onAttachedToWindow()scrollManager?.addCandidate(this)}override fun onDetachedFromWindow() {super.onDetachedFromWindow()scrollManager?.removeCandidate(this)}fun setScrollManager(scrollManager: ScrollManager){this.scrollManager = scrollManageronScrolleChangedListener = 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? = nulloverride fun onAttachedToWindow() {super.onAttachedToWindow()if (inflated.getAndSet(true).not()){inflate(context, getLayoutId(), this).also(::initLayout)}val scrollLayout = getScrollLayout() ?:returnscrollManager?.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 = thisif (childCount == 1 && getChildAt(0) is ViewGroup){measureParent = getChildAt(0) as ViewGroup}var sChildWidth = 0for (i in 0 until measureParent.childCount){sChildWidth += measureParent.getChildAt(i).measuredWidth}// 在header里面计算最大滚动offsetscrollManager?.setMaxScrollOffset((sChildWidth - measureParent.measuredWidth).toFloat())}}}scrollLayout.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)}override fun onDetachedFromWindow() {super.onDetachedFromWindow()clearScrollLayoutAndManager()}fun setScrollManager(scrollManager: ScrollManager){this.scrollManager = scrollManagergetScrollLayout()?.also {if (it.onScrolleChangedListener == null){it.onScrolleChangedListener = HeaderGestureLayoutOnScrollChangedListener(scrollManager)}}}private fun clearScrollLayoutAndManager(){getScrollLayout()?.onScrolleChangedListener = nullscrollManager?.also {it.removeCandidate(this)scrollManager = null}}protected abstract fun getLayoutId(): Intprotected 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? = nulloverride 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 = 100override 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"><TextViewandroid: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" /><Viewandroid:layout_width="1dp"android:layout_height="match_parent"android:background="#cccccc" /><ItemGestureLayoutandroid: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? = nulloverride fun getLayoutId(): Int = R.layout.table_header_testoverride 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"><TextViewandroid: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" /><Viewandroid:layout_width="1dp"android:layout_height="match_parent"android:background="#cccccc" /><GestureLayoutandroid: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 = scrollManagerrecycler.adapter = adapterrecycler.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"><HeaderLayoutandroid:id="@+id/header_layout"android:layout_width="match_parent"android:layout_height="wrap_content" /><androidx.recyclerview.widget.RecyclerViewandroid:id="@+id/recycler"android:layout_width="match_parent"android:layout_height="wrap_content" /></LinearLayout>

效果图
使用RecyclerView开发TabView

接下来解决滑动冲突的问题。为什么这个问题要憋到这里才说这么解决?因为只有将GestureLayout放到RecyclerView里面,这个问题才会特别明显,明显到影响正常使用。如果在上面编写GestureLayout就顺便加入解决的代码,那就对这个问题没什么感知。
先看一看有问题的效果图
使用RecyclerView开发TabView
可以看到,每次水平滑动时,只要有垂直滑动,就会打断水平滑动,体验起来非常糟心。解决方式也很简单,只要在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 - initialTouchXlastTouchX = xif (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 - xif (scrollState != SCROLL_STATE_DRAGGING && Math.abs(dx) > touchSlop) {scrollState = SCROLL_STATE_DRAGGING// 新增的代码parent.requestDisallowInterceptTouchEvent(true)}if (scrollState == SCROLL_STATE_DRAGGING) {lastTouchX = xonScrolleChangedListener?.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 = scrollManagerthis.clickArea = clickAreaonScrolleChangedListener = ItemGestureLayoutOnScrollChangedListener(scrollManager)clickArea?.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {override fun onViewAttachedToWindow(v: View) {// 当clickArea attach时,add到ScrollManagerscrollManager.also {it.addCandidate(v)}}override fun onViewDetachedFromWindow(v: View) {// 当clickArea detach时,从ScrollManager removescrollManager.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个参数传入clickAreavh.scrollLayout.setScrollManagerAndClickArea(it, vh.clickArea)}}override fun onBindViewHolder(holder: ViewHolder, position: Int) {holder.apply {// 这里记得改为clickArea,不要使用scrollLayoutscrollManager?.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 = 150fprivate var barHeight = 10fprivate var barMarginHorizontal = 20fprivate var barMarginBottom = 20fprivate var barMarginFirstColumn = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 100f, recyclerView.context.resources.displayMetrics)private var scrollPosition = 0fprivate var isShowing = falseprivate var barShowingTime = 500Lprivate val rect = RectF()private val paint = Paint().also {it.isAntiAlias = trueit.style = Paint.Style.FILLit.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,就returnif (isShowing.not()) {return}val width = parent.widthval height = parent.heightval offset = (width - 2 * barMarginHorizontal - barMarginFirstColumn.toInt() - barWidth) * scrollPositionval left = barMarginHorizontal + barMarginFirstColumn.toInt() + offset.toInt()val right = left + barWidthval bottom = height - barMarginBottomval top = bottom - barHeightrect.left = leftrect.top = toprect.right = rightrect.bottom = bottomval rx = barHeight / 2val ry = barHeight / 2c.drawRoundRect(rect, rx, ry, paint)}override fun updateScrollWeight(wieght: Float) {scrollPosition = wieghtisShowing = true// 这个invalidate是我自己写的,最终是调用RecyclerView.invalidateItemDecorations刷新ItemDecoration// 通过这个方法,就可以实时更新scrollPositioninvalidate()}override fun startCountToHide() {handler.removeCallbacks(dismissAction)handler.postDelayed(dismissAction, barShowingTime)}fun setBarWidth(barWidth: Float) {if (this.barWidth == barWidth) {return}this.barWidth = barWidthinvalidate()}fun setBarHeight(barHeight: Float) {if (this.barHeight == barHeight) {return}this.barHeight = barHeightinvalidate()}fun setBarMarginHorizontal(barMarginHorizontal: Float) {if (this.barMarginHorizontal == barMarginHorizontal) {return}this.barMarginHorizontal = barMarginHorizontalinvalidate()}fun setBarMarginBottom(barMarginBottom: Float) {if (this.barMarginBottom == barMarginBottom) {return}this.barMarginBottom = barMarginBottominvalidate()}fun setBarMarginFirstColum(barMarginFirstColumn: Float) {if (this.barMarginFirstColumn == barMarginFirstColumn) {return}this.barMarginFirstColumn = barMarginFirstColumninvalidate()}fun setBarShowingTime(barShowingTime: Long){this.barShowingTime = barShowingTime}fun setBarColor(color: Int) {if (paint.color == color){return}paint.color = colorinvalidate()}private fun invalidate() {recyclerView.invalidateItemDecorations()}
}

Activity

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

效果图:
使用RecyclerView开发TabView
可以看到,效果已经和最上面的图片一样了,实现方式还是比较简单的。

好了通过上面这么多代码,就做出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方法,还是比较方便的。