概述
最近安卓自定义view的常识看的很熟,可是却很久没着手了,这几天用kotlin手撕了原先一个左滑删去的RecyclerView,居然弄得有点懵逼。后边又慢慢改善、加东西,发现这样一个比如下来,自定义View以及事情分发的常识居然覆盖的差不多了,所以有了写博客的想法。下面我会从我的思路一点点的写下去,碰到的各种问题便是常识的实践应用了,经过问题学常识,我觉得这样的方式非常好!
需求
这儿我要做的是一个左滑删去列表项的功用,之前拿过一个别人的用,所以有了一点思路,可是不深刻。所以我开始从零动身,先写个大致思路再一步步去处理,首要必定的是经过继承RecyclerView去实现,后边思路大致如下:
- 在 down 事情中,判别在列表内方位,得到对应 item
- 阻拦 move 事情,item 跟从滑动,最大间隔为删去按钮长度
- 在 up 事情中,确定终究状况,固定 item 方位
终究作用
编写代码I
根据上面三点思路,我刷刷地就写下了下面的代码:
class SlideDeleteRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
//流通滑动
private var mScroller = Scroller(context)
//当时选中item
private var mItem: ViewGroup? = null
//上次按下横坐标
private var mLastX = 0f
override fun onInterceptTouchEvent(e: MotionEvent?): Boolean {
e?.let {
when(e.action) {
MotionEvent.ACTION_DOWN -> {
//获取点击方位
getSelectItem(e)
//设置点击的横坐标
mLastX = e.x
}
MotionEvent.ACTION_MOVE -> {
//不管左右都应该让item跟从滑动
moveItem(e)
//阻拦事情
return true
}
MotionEvent.ACTION_UP -> {
//判别成果
stopMove(e)
}
}
}
return super.onInterceptTouchEvent(e)
}
//滑动完毕
//版别一:判别一下完毕的方位,弥补或康复方位
private fun stopMove(e: MotionEvent) {
mItem?.let {
val dx = e.x - mLastX
//假如移动过半了,应该断定左滑成功
val deleteWidth = it.getChildAt(it.childCount - 1).width
if (abs(dx) >= deleteWidth / 2) {
//触发移动
val left = if (dx > 0) {
deleteWidth - dx
}else {
- deleteWidth + dx
}
mScroller.startScroll(0, 0, left.toInt(),0)
invalidate()
}else {
//假如移动没过半应该康复状况
mScroller.startScroll(0, 0, - dx.toInt(),0)
invalidate()
}
//铲除状况
mLastX = 0f
mItem = null
}
}
//移动item
//版别一:绝对值小于删去按钮长度随意移动,大于则不移动
private fun moveItem(e: MotionEvent) {
mItem?.let {
val dx = e.x - mLastX
//这儿默认最后一个view是删去按钮
if (abs(dx) < it.getChildAt(it.childCount - 1).width) {
//触发移动
mScroller.startScroll(0, 0, dx.toInt(), 0)
invalidate()
}
}
}
//获取点击方位
//版别一:经过点击的y坐标除于item高度得出
private fun getSelectItem(e: MotionEvent) {
val firstChild = getChildAt(0)
firstChild?.let {
val pos = (e.x / firstChild.height).toInt()
mItem = getChildAt(pos) as ViewGroup
}
}
//流通地滑动
override fun computeScroll() {
if (mScroller.computeScrollOffset()) {
mItem?.scrollBy(mScroller.currX, mScroller.currY)
postInvalidate()
}
}
}
留意啊,这儿的代码是没法用的,滑动后选不中正确的item,间隔也有问题,所以里边有许多问题!
kotlin的结构
其实上来最懵逼的便是kotlin的结构函数,自己写了几次,感觉都不对,仍是搜了下,有两种写法,我仍是觉得运用JvmOverloads的比较方便,不过好像在API版别>21时还有个defStyleRes,我这就不相叙了,能够查资料。
获取的item方位不对
这儿获取的item显着不对,其实这个问题很好发现,由于事情的x是屏幕的x啊,这儿运用列表去核算显着不行,而且考虑了可见性吗?考虑可滑动隐藏了吗?考虑了第一个item子显示部分吗? 结合上面这些问题,应该如何去正确获取item的方位呢?看下面代码:
private fun getSelectItem(e: MotionEvent) {
val frame = Rect()
forEach {
if (it.visibility != GONE) {
it.getHitRect(frame)
if (frame.contains(e.x.toInt(), e.y.toInt())) {
mItem = it as ViewGroup
}
}
}
}
这儿参阅了别人的代码,经过遍历子item,查看事情坐标是否在其中,在的话得到选中的item,不再需求position了,仍是挺好理解的。
移动的核算不对
上面的代码将mLastX只记载down事情,而每次的是事情和dwon事情横坐标差值,显着错了。 首要mLastX这儿应该记载的是每个事情的x,包含move的事情,移动的差值应该是一个小的差值。
MotionEvent.ACTION_MOVE -> {
//移动控件
moveItem(e)
//更新点击的横坐标
mLastX = e.x
//阻拦事情
return true
}
private fun moveItem(e: MotionEvent) {
mItem?.let {
val dx = mLastX - e.x
//查看mItem移动后应该在[-deleteLength, 0]内
val deleteWidth = it.getChildAt(it.childCount - 1).width
if ((it.scrollX + dx) <= deleteWidth && (it.scrollX + dx) >= 0) {
//触发移动
it.scrollBy(dx.toInt(), 0)
}
}
}
滑动完毕完毕判别不对
上面的mLastX修改后,滑动完毕完毕的判别不对,而且原本便是不对的哈!mScroller的移动就错了,正确的看下面:
private fun stopMove() {
mItem?.let {
//假如移动过半了,应该断定左滑成功
val deleteWidth = it.getChildAt(it.childCount - 1).width
if (abs(it.scrollX) >= deleteWidth / 2) {
//触发移动至彻底打开
mScroller.startScroll(it.scrollX, 0, - deleteWidth,0)
invalidate()
}else {
//假如移动没过半应该康复状况
mScroller.startScroll(it.scrollX, 0, 0,0)
invalidate()
}
//铲除状况
mLastX = 0f
mItem = null
}
}
编写代码II
改完上面代码大致就有了第二版,下面看悉数代码,看看还有什么问题啊:
class SlideDeleteRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
//流通滑动
private var mScroller = Scroller(context)
//当时选中item
private var mItem: ViewGroup? = null
//上次按下横坐标
private var mLastX = 0f
override fun onInterceptTouchEvent(e: MotionEvent?): Boolean {
e?.let {
when(e.action) {
MotionEvent.ACTION_DOWN -> {
//获取点击方位
getSelectItem(e)
//设置点击的横坐标
mLastX = e.x
}
MotionEvent.ACTION_MOVE -> {
//移动控件
moveItem(e)
//更新点击的横坐标
mLastX = e.x
//阻拦事情
return true
}
MotionEvent.ACTION_UP -> {
//判别成果
stopMove()
}
}
}
return super.onInterceptTouchEvent(e)
}
//滑动完毕
//版别一:判别一下完毕的方位,弥补或康复方位
//问题:mLast不该该是down的方位
//版别二:
private fun stopMove() {
mItem?.let {
//假如移动过半了,应该断定左滑成功
val deleteWidth = it.getChildAt(it.childCount - 1).width
if (abs(it.scrollX) >= deleteWidth / 2) {
//触发移动至彻底打开
mScroller.startScroll(it.scrollX, 0, - deleteWidth,0)
invalidate()
}else {
//假如移动没过半应该康复状况
mScroller.startScroll(it.scrollX, 0, 0,0)
invalidate()
}
//铲除状况
mLastX = 0f
mItem = null
}
}
//移动item
//版别一:绝对值小于删去按钮长度随意移动,大于则不移动
//问题:移动方向反了,而且左右能够滑动,没有限定住规模,mLast仅仅记住down的方位
//版别二:经过全体移动的数值,和每次更新的数值,判别是否在规模内,再移动
private fun moveItem(e: MotionEvent) {
mItem?.let {
val dx = mLastX - e.x
//查看mItem移动后应该在[-deleteLength, 0]内
val deleteWidth = it.getChildAt(it.childCount - 1).width
if ((it.scrollX + dx) <= deleteWidth && (it.scrollX + dx) >= 0) {
//触发移动
it.scrollBy(dx.toInt(), 0)
}
}
}
//获取点击方位
//版别一:经过点击的y坐标除于item高度得出
//问题:没考虑列表项的可见性、列表滑动的状况,而且x和屏幕有关不仅仅是列表
//版别二:经过遍历子view查看事情在哪个view内,得到点击的item
private fun getSelectItem(e: MotionEvent) {
//取得第一个可见的item的position
val frame = Rect()
forEach {
if (it.visibility != GONE) {
it.getHitRect(frame)
if (frame.contains(e.x.toInt(), e.y.toInt())) {
mItem = it as ViewGroup
}
}
}
}
//流通地滑动
override fun computeScroll() {
if (mScroller.computeScrollOffset()) {
mItem?.scrollBy(mScroller.currX, mScroller.currY)
postInvalidate()
}
}
}
代码改完,运行,诶,怎样只能滑动一小下?打断点试一下,选中的item正确了,可是怎样ACTION_MOVE只触发一次?怎样ACTION_UP不触发呢?这儿就要留意下ACTION_MOVE里的代码:
MotionEvent.ACTION_MOVE -> {
//移动控件
moveItem(e)
//更新点击的横坐标
mLastX = e.x
//阻拦事情
return true
}
这儿回来了true?阻拦事情?那后续的一系列事情不便是被当时view阻拦了吗?公然仅仅一个onInterceptTouchEvent是搞不定的啊! 其实这儿还有一个隐藏问题,computeScroll里边真的写对了吗?scrollBy和scrollTo有了解吗? 下面看再次改善的代码,首要便是改的上面两点,改动篇幅有点大,就全贴出来了。
编写代码III
class SlideDeleteRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
//流通滑动
private var mScroller = Scroller(context)
//当时选中item
private var mItem: ViewGroup? = null
//上次按下横坐标
private var mLastX = 0f
override fun onInterceptTouchEvent(e: MotionEvent?): Boolean {
e?.let {
when(e.action) {
MotionEvent.ACTION_DOWN -> {
//获取点击方位
getSelectItem(e)
//设置点击的横坐标
mLastX = e.x
}
MotionEvent.ACTION_MOVE -> {
//判别是否阻拦
return moveItem(e)
}
// MotionEvent.ACTION_UP -> {
// //判别成果
// stopMove()
// }
}
}
return super.onInterceptTouchEvent(e)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(e: MotionEvent?): Boolean {
e?.let {
when(e.action) {
//阻拦了ACTION_MOVE后,后边一系列event都会交到本view处理
MotionEvent.ACTION_MOVE -> {
//移动控件
moveItem(e)
//更新点击的横坐标
mLastX = e.x
}
MotionEvent.ACTION_UP -> {
//判别成果
stopMove()
}
}
}
return super.onTouchEvent(e)
}
//滑动完毕
//版别一:判别一下完毕的方位,弥补或康复方位
//问题:mLast不该该是down的方位
//版别二:改善成果判别
//问题:onInterceptTouchEvent的ACTION_UP不触发
//版别三:改善弥补或康复方位的逻辑
private fun stopMove() {
mItem?.let {
//假如移动过半了,应该断定左滑成功
val deleteWidth = it.getChildAt(it.childCount - 1).width
if (abs(it.scrollX) >= deleteWidth / 2f) {
//触发移动至彻底打开
mScroller.startScroll(it.scrollX, 0, deleteWidth - it.scrollX,0)
invalidate()
}else {
//假如移动没过半应该康复状况
mScroller.startScroll(it.scrollX, 0, -it.scrollX,0)
invalidate()
}
//铲除状况
mLastX = 0f
//不能为null,后续流通滑动要用到
//mItem = null
}
}
//移动item
//版别一:绝对值小于删去按钮长度随意移动,大于则不移动
//问题:移动方向反了,而且左右能够滑动,没有限定住规模,mLast仅仅记住down的方位
//版别二:经过全体移动的数值,和每次更新的数值,判别是否在规模内,再移动
//问题:onInterceptTouchEvent的ACTION_MOVE只触发一次
//版别三:放在onTouchEvent内履行,而且在onInterceptTouchEvent给出一个阻拦判别
private fun moveItem(e: MotionEvent): Boolean {
mItem?.let {
val dx = mLastX - e.x
//查看mItem移动后应该在[-deleteLength, 0]内
val deleteWidth = it.getChildAt(it.childCount - 1).width
if ((it.scrollX + dx) <= deleteWidth && (it.scrollX + dx) >= 0) {
//触发移动
it.scrollBy(dx.toInt(), 0)
return true
}
}
return false
}
//获取点击方位
//版别一:经过点击的y坐标除于item高度得出
//问题:没考虑列表项的可见性、列表滑动的状况,而且x和屏幕有关不仅仅是列表
//版别二:经过遍历子view查看事情在哪个view内,得到点击的item
//问题:没有问题,成功拿到了mItem
private fun getSelectItem(e: MotionEvent) {
//取得第一个可见的item的position
val frame = Rect()
//避免点击其他地方,坚持上一个item
mItem = null
forEach {
if (it.visibility != GONE) {
it.getHitRect(frame)
if (frame.contains(e.x.toInt(), e.y.toInt())) {
mItem = it as ViewGroup
}
}
}
}
//流通地滑动
override fun computeScroll() {
if (mScroller.computeScrollOffset()) {
mItem?.scrollTo(mScroller.currX, mScroller.currY)
postInvalidate()
}
}
}
把上面代码运行下,公然就十分完美了,可是是不是觉得没彻底搞定啊?别急下面咱们再加点东西.
优化
优化一:TouchSlop
TouchSlop是一个移动的最小间隔,由系统提供,能够用它来判别一个滑动间隔是否有用。
优化二:VelocityTracker
VelocityTracker是一个速度核算的东西,由native提供,能够核算移动像素点的速度,咱们能够利用它判别当滑动速度很快时也打开删去按钮。
优化三:GestureDetector
GestureDetector是手势操控类,能够很方便的判别各种手势,咱们这能够设计它双击打开删去按钮。
优化后代码
class SlideDeleteRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
//系统最小移动间隔
private val mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
//最小有用速度
private val mMinVelocity = 600
//添加手势操控,双击快速完结侧滑,仍是为了操练
private var isDoubleClick = false
private var mGestureDetector: GestureDetector
= GestureDetector(context, object : GestureDetector.SimpleOnGestureListener(){
override fun onDoubleTap(e: MotionEvent?): Boolean {
e?.let { event->
getSelectItem(event)
mItem?.let {
val deleteWidth = it.getChildAt(it.childCount - 1).width
//触发移动至彻底打开deleteWidth
if (it.scrollX == 0) {
mScroller.startScroll(0, 0, deleteWidth, 0)
}else {
mScroller.startScroll(it.scrollX, 0, -it.scrollX, 0)
}
isDoubleClick = true
invalidate()
return true
}
}
//不进行阻拦,仅仅作为东西判别下双击
return false
}
})
//运用速度操控器,添加侧滑速度断定滑动成功,首要为了是操练
//VelocityTracker 由 native 实现,需求及时释放内存
private var mVelocityTracker: VelocityTracker? = null
//流通滑动
private var mScroller = Scroller(context)
//当时选中item
private var mItem: ViewGroup? = null
//上次事情的横坐标
private var mLastX = 0f
//当时RecyclerView被上层viewGroup分发到事情,一切事情都会经过dispatchTouchEvent给到
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
//
mGestureDetector.onTouchEvent(ev)
return super.dispatchTouchEvent(ev)
}
//viewGroup对子控件的事情阻拦,一旦阻拦,后续事情序列不会再调用onInterceptTouchEvent
override fun onInterceptTouchEvent(e: MotionEvent?): Boolean {
e?.let {
when (e.action) {
MotionEvent.ACTION_DOWN -> {
//这儿的优化会阻挠双击滑动的运用,实践也没什么好优化的
// //避免快速按下状况出问题
// if (!mScroller.isFinished) {
// mScroller.abortAnimation()
// }
//获取点击方位
getSelectItem(e)
//设置点击的横坐标
mLastX = e.x
}
MotionEvent.ACTION_MOVE -> {
//判别是否阻拦
//假如阻拦了ACTION_MOVE,后续事情就不触发onInterceptTouchEvent了
return moveItem(e)
}
//阻拦了ACTION_MOVE,ACTION_UP也不会触发
// MotionEvent.ACTION_UP -> {
// //判别成果
// stopMove()
// }
}
}
return super.onInterceptTouchEvent(e)
}
//阻拦后对事情的处理,或许子控件不处理,回来到父控件处理,在onTouch之后,在onClick之前
//假如不消耗,则在同一事情序列中,当时View无法再次接受事情
//performClick会被onTouchEvent阻拦,咱们这不需求点击,全都交给super实现去了
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(e: MotionEvent?): Boolean {
e?.let {
when (e.action) {
//没有阻拦,也不能阻拦,所以不需求处理
// MotionEvent.ACTION_DOWN -> {}
//阻拦了ACTION_MOVE后,后边一系列event都会交到本view处理
MotionEvent.ACTION_MOVE -> {
//移动控件
moveItem(e)
//更新点击的横坐标
mLastX = e.x
}
MotionEvent.ACTION_UP -> {
//判别成果
stopMove()
}
}
}
return super.onTouchEvent(e)
}
//滑动完毕
//版别一:判别一下完毕的方位,弥补或康复方位
//问题:mLast不该该是down的方位
//版别二:改善成果判别
//问题:onInterceptTouchEvent的ACTION_UP不触发
//版别三:改善弥补或康复方位的逻辑
private fun stopMove() {
mItem?.let {
//假如移动过半了,应该断定左滑成功
val deleteWidth = it.getChildAt(it.childCount - 1).width
//假如整个移动过程速度大于600,也断定滑动成功
//留意假如没有阻拦ACTION_MOVE,mVelocityTracker是没有初始化的
var velocity = 0f
mVelocityTracker?.let { tracker ->
tracker.computeCurrentVelocity(1000)
velocity = tracker.xVelocity
}
//判别完毕状况,移动过半或许向左速度很快都打开
if ( (abs(it.scrollX) >= deleteWidth / 2f) || (velocity < - mMinVelocity) ) {
//触发移动至彻底打开
mScroller.startScroll(it.scrollX, 0, deleteWidth - it.scrollX, 0)
invalidate()
}else {
//假如移动没过半应该康复状况,或许向右移动很快则康复到原来状况
mScroller.startScroll(it.scrollX, 0, -it.scrollX, 0)
invalidate()
}
//铲除状况
mLastX = 0f
//不能为null,后续mScroller要用到
//mItem = null
//mVelocityTracker由native实现,需求及时释放
mVelocityTracker?.apply {
clear()
recycle()
}
mVelocityTracker = null
}
}
//移动item
//版别一:绝对值小于删去按钮长度随意移动,大于则不移动
//问题:移动方向反了,而且左右能够滑动,没有限定住规模,mLast仅仅记住down的方位
//版别二:经过全体移动的数值,和每次更新的数值,判别是否在规模内,再移动
//问题:onInterceptTouchEvent的ACTION_MOVE只触发一次
//版别三:放在onTouchEvent内履行,而且在onInterceptTouchEvent给出一个阻拦判别
@SuppressLint("Recycle")
private fun moveItem(e: MotionEvent): Boolean {
mItem?.let {
val dx = mLastX - e.x
//最小的移动间隔应该舍弃,onInterceptTouchEvent不阻拦,onTouchEvent内才更新mLastX
if(abs(dx) > mTouchSlop) {
//查看mItem移动后应该在[-deleteLength, 0]内
val deleteWidth = it.getChildAt(it.childCount - 1).width
if ((it.scrollX + dx) <= deleteWidth && (it.scrollX + dx) >= 0) {
//触发移动
it.scrollBy(dx.toInt(), 0)
//触发速度核算
//这儿Recycle不存在问题,一旦回来true,就会阻拦事情,就会抵达ACTION_UP去回收
mVelocityTracker = mVelocityTracker ?: VelocityTracker.obtain()
mVelocityTracker!!.addMovement(e)
return true
}
}
}
return false
}
//获取点击方位
//版别一:经过点击的y坐标除于item高度得出
//问题:没考虑列表项的可见性、列表滑动的状况,而且x和屏幕有关不仅仅是列表
//版别二:经过遍历子view查看事情在哪个view内,得到点击的item
//问题:没有问题,成功拿到了mItem
private fun getSelectItem(e: MotionEvent) {
//取得第一个可见的item的position
val frame = Rect()
//避免点击其他地方,坚持上一个item
mItem = null
forEach {
if (it.visibility != GONE) {
it.getHitRect(frame)
if (frame.contains(e.x.toInt(), e.y.toInt())) {
mItem = it as ViewGroup
}
}
}
}
//流通地滑动
override fun computeScroll() {
if (mScroller.computeScrollOffset()) {
mItem?.scrollTo(mScroller.currX, mScroller.currY)
postInvalidate()
}
}
}
TouchSlop、VelocityTracker和GestureDetector的用法都很简单,可是有一点必须得说一下,那便是在dispatchTouchEvent中传递事情给GestureDetector,为什么呢?由于onInterceptTouchEvent阻拦后就搜不到事情了,onTouchEvent的履行和本身及子控件有关,有不确定性,只要dispatchTouchEvent中的事情必定会收到!
总结
一篇文章下来,代码贴的有点多了,篇幅很长,可是假如细心品的话,你会发现从事情分发到阻拦都从问题里边学到了,几种滑动方式以及滑动的相对性也触及了,坐标系也有了必定理解,其他几个东西TouchSlop、VelocityTracker和GestureDetector都用到了,还算能够吧!
后续订正
处理流通问题
mTouchSlop(值为16)的运用造成了滑动卡顿,或许底子滑不动,注释了代码:
//if(abs(dx) > mTouchSlop) {
//查看mItem移动后应该在[-deleteLength, 0]内
val deleteWidth = it.getChildAt(it.childCount - 1).width
if ((it.scrollX + dx) <= deleteWidth && (it.scrollX + dx) >= 0) {
//触发移动
it.scrollBy(dx.toInt(), 0)
//触发速度核算
//这儿Recycle不存在问题,一旦回来true,就会阻拦事情,就会抵达ACTION_UP去回收
mVelocityTracker = mVelocityTracker ?: VelocityTracker.obtain()
mVelocityTracker!!.addMovement(e)
return true
}
//}