今天介绍下项目中用到的侧滑删除
之前的文章只是实现了item的侧滑,但在项目中需要添加滑动的优化以及以后的扩展
扩展效果图:
分析:
- 滑动优化是指从部分展开过度到安全展开,添加动画机制或者使用Scroller控制滑动,使其不那么生硬
- 添加移动速度,进行展开判断
- 提供定制的item,封装菜单view与内容view,各个部位的点击
一、滑动优化
这里本来想借助Scroller来控制滑动,但是发现关闭进行中同时打开菜单,有冲突。解决这个冲突,想到了借助item里的Scroller,进而更新。而不是recycleview的Scroller。这样比较繁琐,以此放弃采用动画(ObjectAnimator)。有兴趣的朋友可以尝试下Scroller。
ObjectAnimator采用默认的插值器,因scrollTo方法的参数是Int类型,以此如下:
private val objectUpdateListener = object : ValueAnimator.AnimatorUpdateListener {
override fun onAnimationUpdate(animation: ValueAnimator) {
if (animation is ObjectAnimator) {
var target = animation.target as View
var slide = animation.animatedValue as Int
target.scrollTo(slide, 0)
}
}
}
private val closeAnimator: ObjectAnimator by lazy {
ObjectAnimator.ofInt(this@ItemSlideRecycleView, "slideClose", 0, 0)
.apply {
duration = 200
addUpdateListener(objectUpdateListener)
}
}
private val openAnimator: ObjectAnimator by lazy {
ObjectAnimator.ofInt(this@ItemSlideRecycleView, "slideOpen", 0, 0)
.apply {
duration = 200
addUpdateListener(objectUpdateListener)
}
}
private fun startCloseAnimator(view: View, start: Int, end: Int) {
closeAnimator.target = view
closeAnimator.setIntValues(start, end)
closeAnimator.start()
}
private fun startOpenAnimator(view: View, start: Int, end: Int) {
openAnimator.target = view
openAnimator.setIntValues(start, end)
openAnimator.start()
}
修改打开与关闭菜单方法:
/**
* 菜单展开
*/
private fun showMenu(view: View) {
startOpenAnimator(view, view.scrollX, mMenuWidth)
mMenuShowAllTag = true
}
/**
* 菜单关闭
*/
private fun closeMenu(view: View) {
if (closeAnimator.isRunning) {
closeAnimator.cancel()
(closeAnimator.target as View).scrollTo(0, 0)
}
startCloseAnimator(view, view.scrollX, 0)
Log.e(TAG, "closeMenu: ${view.hashCode()}")
mMenuShowAllTag = false
}
二、添加手指滑动速度判断
当手指的移动速度大于一定值时,也认为是打开菜单的意图。所以在checkEffectiveSlideLength()添加判断。
1.首先需要初始化相关类VelocityTracker
private val mVelocityTracker: VelocityTracker by lazy {
VelocityTracker.obtain()
}
2.添加速度判断
/**
* 最小有效滑动速度
* 向左滑,速度为负数
*/
private val MIN_SPEED = -400
/**
* 检测滑动速度是否符合我们的要求
*/
private fun checkEffectiveSpeed(): Boolean {
mVelocityTracker.computeCurrentVelocity(1000)
return mVelocityTracker.xVelocity <= MIN_SPEED
}
3.我们需要在onInterceptTouchEvent方法处理ACTION_MOVE事件以及onTouchEvent方法处理ACTION_UP事件添加速度判断。因为一个决定是否拦截此事时间,一个判断是否展开菜单,都需要速度的判断。
override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
mVelocityTracker.addMovement(e)
when (e.action) {
/*代码省略*/
MotionEvent.ACTION_MOVE -> {
if (checkEffectiveSlide(e.x, e.y)) {
//查找当前菜单
findMotionView(downLastX.toInt(), downLastY.toInt())
slideEffiectiveTag = true
return true
}
}
/*代码省略*/
}
var onInterceptTouchEvent = super.onInterceptTouchEvent(e)
Log.e(TAG, "onInterceptTouchEvent: result=$onInterceptTouchEvent")
return onInterceptTouchEvent
}
/**
* 检测滑动是否符合我们的要求
*/
private fun checkEffectiveSlide(x: Float, y: Float): Boolean {
if (checkEffectiveSpeed()) {
return true
}
var changeX = lastX - x
var changeY = lastY - y
if (changeX > mMinSlide && changeX > Math.abs(changeY)) {//水平向右滑动
return true
}
return false
}
override fun onTouchEvent(e: MotionEvent): Boolean {
mVelocityTracker.addMovement(e)
when (e.action) {
/*代码省略*/
MotionEvent.ACTION_UP -> {
Log.e(TAG, "onTouchEvent: ACTION_UP $slideEffiectiveTag")
//此标志恢复初始值
onTouchEventDownTag = false
if (slideEffiectiveTag) {
if (!mMenuShowAllTag)
upFinalMoveToMenuView()
//此标志恢复初始值
slideEffiectiveTag = false
return true
} else {
//若没有有效滑动,但已展开,则关闭菜单
if (mMenuShowAllTag) {
closeMenu()
}
}
}
}
var onTouchEvent = super.onTouchEvent(e)
Log.e(TAG, "onTouchEvent: result=$onTouchEvent")
return onTouchEvent
}
/**
* 手指抬起,对目标view进行最后的移动
* 即决定菜单是否展开或关闭
*/
private fun upFinalMoveToMenuView() {
mMenuView?.let {
if (it.scrollX >= mMenuWidth / 2f || checkEffectiveSpeed()) {
showMenu(it)
} else {
closeMenu(it)
}
}
}
三、封装内容与菜单以及点击
这个说什么好呢,好吧,先定义recycleview的适配器抽象类(ItemSlideAdapter),然后抽离相关方法。
1.指定容器viewgroup把内容和菜单分离。由适配器进行添加,这里使用了LinearLayout,如下:
private fun createView(context: Context, contentView: View, menuView: View): ViewGroup {
return LinearLayout(context).apply {
layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.WRAP_CONTENT
)
orientation = LinearLayout.HORIZONTAL
addView(contentView)
var layoutParams = menuView.layoutParams
if (null == layoutParams || layoutParams.width <= 0) {
throw RuntimeException("getMenuView方法得到的view,必须设置固定的宽度")
}
addView(menuView)
}
}
2.定义内容Holder(ContentViewHolder)与菜单holder(MenuViewHolder)与内容view和菜单view进行绑定,有点类似listview的ViewHolder。这将在其子类实现。如下:
abstract class ContentViewHolder(val view: View)
abstract class MenuViewHolder(val view: View)
3.构造生成ContentViewHolder与MenuViewHolder抽象方法,提供viewType参数是为了扩展。
//C : ItemSlideAdapter.ContentViewHolder, M : ItemSlideAdapter.MenuViewHolder
abstract fun getMenuViewHolder(parent: ViewGroup, viewType: Int): M
abstract fun getContentViewHolder(parent: ViewGroup, viewType: Int): C
4.创建RecyclerView.ViewHolder的实现类。因为已经将内容和菜单分离下去了,item成了一个空壳,所以可以将其确定。但要包含ContentViewHolder和MenuViewHolder,如下:
class ItemSlideViewHolder(
view: ViewGroup,
var contentViewHolder: ContentViewHolder,
var menuViewHolder: MenuViewHolder
) : RecyclerView.ViewHolder(view)
5.这样的话就可以onCreateViewHolder方法实现了,如下:
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemSlideAdapter.ItemSlideViewHolder {
var contentViewHolder = getContentViewHolder(parent, viewType)
var menuViewHolder = getMenuViewHolder(parent, viewType)
return ItemSlideAdapter.ItemSlideViewHolder(
createView(
parent.context,
contentViewHolder.view,
menuViewHolder.view
), contentViewHolder, menuViewHolder
)
}
6.点击的封装,想了一下这个还是不加了吧,在抽象类的实现类里自行加入。因为菜单子view的数量不定,还有内容子view的数量也不定,哪些需要点击也不定。除非都已确定,非要写的话,需要添加好多方法,得不偿失。
7.因此抽象类的全部代码,如下
package com.xinheng.leftslidedeleterecycleview
import android.content.Context
import android.support.v7.widget.RecyclerView
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import java.lang.RuntimeException
/**
* Created by XinHeng on 2019/03/07.
* describe:
*/
abstract class ItemSlideAdapter<C : ItemSlideAdapter.ContentViewHolder, M : ItemSlideAdapter.MenuViewHolder> :
RecyclerView.Adapter<ItemSlideAdapter.ItemSlideViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemSlideAdapter.ItemSlideViewHolder {
var contentViewHolder = getContentViewHolder(parent, viewType)
var menuViewHolder = getMenuViewHolder(parent, viewType)
return ItemSlideAdapter.ItemSlideViewHolder(
createView(
parent.context,
contentViewHolder.view,
menuViewHolder.view
), contentViewHolder, menuViewHolder
)
}
override fun onBindViewHolder(holder: ItemSlideViewHolder, position: Int) {
var contentViewHolder = holder.contentViewHolder
var menuViewHolder = holder.menuViewHolder
onBindHolder(contentViewHolder, menuViewHolder, position)
}
private fun createView(context: Context, contentView: View, menuView: View): ViewGroup {
return LinearLayout(context).apply {
layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.WRAP_CONTENT
)
orientation = LinearLayout.HORIZONTAL
addView(contentView)
var layoutParams = menuView.layoutParams
if (null == layoutParams || layoutParams.width <= 0) {
throw RuntimeException("getMenuView方法得到的view,必须设置固定的宽度")
}
addView(menuView)
}
}
abstract fun onBindHolder(contentViewHolder: ContentViewHolder, menuViewHolder: MenuViewHolder, position: Int)
abstract fun getMenuViewHolder(parent: ViewGroup, viewType: Int): M
abstract fun getContentViewHolder(parent: ViewGroup, viewType: Int): C
class ItemSlideViewHolder(
view: ViewGroup,
var contentViewHolder: ContentViewHolder,
var menuViewHolder: MenuViewHolder
) : RecyclerView.ViewHolder(view)
abstract class ContentViewHolder(val view: View)
abstract class MenuViewHolder(val view: View)
}
8.提供一个例子,实现了多种菜单与多种内容搭配。GitHub项目地址。效果图如上
四、在我的项目的实际应用
不晓得,当初他们是怎么想的,先上图:(第一行是类似标题可滑动,第二行是具体内容不可滑动)
既然提出了,做呗。还是挺新颖的,哈哈哈。当初想了两个方案:
①分为两个类型的item(A标题,B内容),A可滑动,B不可滑动。
②定制item(包含标题和内容),对mMenuView进行处理,更换view响应scrollTo(scrollBy)方法,即仅使标题响应滑动。
这样比较了一下,第一种方法比较简单。仅需要添加可滑动标志就行,或者item为一个的时候不响应滑动。但是仔细想了一下,这样的话标题的触碰区域较窄,滑动很麻烦,用户体验不好。而第二种可以很好的解决这个问题,并能实现功能。
因此采用第二种方法,recycleview添加接口,在mMenuView中获取我们想滑动的view。具体步骤:
1.添加接口回调
var onItemSlideRecycleListener: OnItemSlideRecycleListener? = null
interface OnItemSlideRecycleListener {
/**
* 获取真正可滑动view
* @param view recycleview列表的item
*/
fun getRealCanSlideMenuView(view: View): ViewGroup
}
/**
* 获取可滑动,且包含菜单的view
* @param view item
*/
private fun getRealMenuView(view: View): ViewGroup {
return if (onItemSlideRecycleListener != null) {
onItemSlideRecycleListener!!.getRealCanSlideMenuView(view)
} else {
view as ViewGroup
}
}
2.在获取菜单宽度的时候,以及所有有关移动菜单的方法,更滑我们想要移动的view
/**
* 移动当前view
*/
private fun moveToMenuView(slide: Int) {
mMenuView?.let {
val view = getRealSlideView(it)
mMenuWidth = view.getChildAt(1).measuredWidth
Log.e("TAG", "moveToMenuView: mMenuWidth=$mMenuWidth")
if (view.scrollX + slide >= mMenuWidth) {
//showMenu(view)
view.scrollTo(mMenuWidth, 0)
mMenuShowAllTag = true
} else {
mMenuShowAllTag = false
view.scrollBy(slide, 0)
}
}
}
/**
* 手指抬起,对目标view进行最后的移动
* 即决定菜单是否展开或关闭
*/
private fun upFinalMoveToMenuView() {
mMenuView?.let { view ->
val it = getRealSlideView(view)
if (it.scrollX >= mMenuWidth / 2f || checkEffectiveSpeed()) {
showMenu(it)
} else {
closeMenu(it)
}
}
}
fun closeMenu() {
mMenuView?.let { view ->
val it = getRealSlideView(view)
closeMenu(it)
}
}
private fun closeNowMenu(x: Int, y: Int) {
if (null != mMenuView && mMenuShowAllTag) {
if (!isNowMenu(x, y)) {
closeMenu(getRealMenuView(mMenuView!!))
//mMenuShowAllTag = false
slideEffiectiveTag = false
}
}
}
3.这里说下item为什么不自定义viewgroup,重写scrollTo(scrollBy)。原因:需要在添加获取水平滑动方法,getScrollX被final修饰无法重写;还有recycleview的item必须是我们的自定义的viewgroup。这样的话也可以,但不利于扩展和后期维护,无形中添加了限制。
4.item的布局文件,以及recycleview的内部接口的实现
<?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="wrap_content"
android:orientation="vertical">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="horizontal">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="horizontal">
<View android:layout_width="0dp" android:layout_height="10dp" android:layout_weight="1"/>
<View android:layout_width="1dp" android:layout_height="30dp" android:background="@color/gray"/>
<TextView android:layout_width="80dp" android:layout_height="30dp" android:text="编辑"
android:gravity="center"/>
<View android:layout_width="1dp" android:layout_height="30dp" android:background="@color/gray"/>
<TextView android:layout_width="80dp" android:layout_height="30dp" android:text="预览"
android:gravity="center"/>
</LinearLayout>
<TextView android:layout_width="60dp" android:layout_height="30dp" android:text="删除" android:gravity="center"
android:textColor="#fff" android:background="@color/colorAccent"/>
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/gray"/>
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:padding="10dp" android:text=" 这样比较了一下,第一种方法比较简单。仅需要添加可滑动标志就行,或者item为一个的时候不响应滑动。"/>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/gray"/>
</LinearLayout>
recycleView.onItemSlideRecycleListener = object : ItemSlideRecycleView.OnItemSlideRecycleListener {
override fun getRealCanSlideMenuView(view: View): ViewGroup {
//仅仅针对此次项目,很明显为第一个子view
//其他请根据具体情况修改
return (view as ViewGroup).getChildAt(0) as ViewGroup
}
}
5.效果图如上。
6.最后有个问题需要改下之前写的recycleview,当recycleview的item为view时会崩溃。因此我们要么在获取当前触碰的菜单view时,添加判断。还有在判断不是上次菜单view时,要将mMenuView归为null。这样的话当item是view时不响应滑动。如下:
7.附上本次的项目代码,GitHub项目地址。