github链接
demo代码
效果图
这个功能是使用RecyclerView开发的,需要解决下面这些问题
- 单个item滚动的问题:左边的view需要固定、手指松开之后,惯性的处理
- 滑动布局子View事件分发冲突的解决
- 多个item联合滚动滚动
- header
- 解决itemView与RecyclerView滑动冲突的问题
- 横向滚动时,显示和隐藏滚动条
带着上面想到的问题,逐一写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方法,还是比较方便的。