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=