记得在之前项目中,有一个需求是文章列表侧滑显示删除按钮。
当时为了实现这个功能,又加上时间紧急,无奈引入了第三方库。
代价大不说,自身也感觉这样做项目,技术不会有大的进步。
于是决定自撸一个侧滑删除控件 — SwipeDeleteMenu。
先上效果图:
如何实现?
乍一看是不是感觉比较复杂比较难?
其实当我们一点一点来拆析的时候,就会发现,也不过如此。
在这里,我分为两大步:
- 布局操作
- 逻辑实现
第一步,布局操作,直接上代码:
public class SwipeDeleteMenu extends ViewGroup {
private Context mContext;
// 右侧菜单测量宽度
private int mRightMenuWidthMeasured;
// 左侧内容区测量宽度
private int mContentWidthMeasured;
// 测量高度
private int mHeightMeasured;
public SwipeDeleteMenu(Context context) {
this(context, null, 0);
}
public SwipeDeleteMenu(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
this.mContext = context;
}
public SwipeDeleteMenu(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
mRightMenuWidthMeasured = 0;
mContentWidthMeasured = 0;
mHeightMeasured = 0;
for (int i = 0; i < this.getChildCount(); i++) {
View childView = this.getChildAt(i);
if (childView.getVisibility() != GONE) {
childView.setClickable(true);
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
mHeightMeasured = this.getMeasuredHeight();
// 内容区
if (i == 0) {
mContentWidthMeasured = childView.getMeasuredWidth();
} else {
// 右侧菜单
mRightMenuWidthMeasured += childView.getMeasuredWidth();
}
}
}
// 测量完成进行保存
setMeasuredDimension(
mContentWidthMeasured + getPaddingLeft() + getPaddingRight(),
mHeightMeasured + getPaddingTop() + getPaddingBottom());
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int rightMenuLeft = 0;
for (int i = 0; i < this.getChildCount(); i++) {
View childView = this.getChildAt(i);
if (childView.getVisibility() != GONE) {
if (i == 0) {
childView.layout(getPaddingLeft(), getPaddingTop(),
getPaddingLeft() + childView.getMeasuredWidth(),
getPaddingTop() + childView.getMeasuredHeight());
rightMenuLeft = getPaddingLeft() + childView.getMeasuredWidth();
} else {
childView.layout(rightMenuLeft, getPaddingTop(),
rightMenuLeft + childView.getMeasuredWidth(),
getPaddingTop() + childView.getMeasuredHeight());
rightMenuLeft += childView.getMeasuredWidth();
}
}
}
}
}
首先要继承 ViewGroup,并重写构造方法、onMeasure、onLayout。
- onMeasure
确定内容区宽度、右侧菜单、整体高度 - onLayout
确定内容布局以及右侧菜单的位置显示
注意:默认内容区宽度充满屏幕。
第二步,逻辑实现,相较第一步会比较难,也不是难,只是相对复杂些。
在第一步中,我们只需完成测量布局工作,很简单。
第二步,我们会牵扯到动画、拦截事件等。接下来看具体实现:
手指向左滑动,显示菜单
向左滑动,那滑动多少才显示?
那么我们需要顶一个变量,来表示滑动界限 mLimitDistance。这个值在 onMeasure 方法中进行赋值。
mLimitDistance = mRightMenuWidth * 5 / 10;
在这里,我给定的是如果滑动距离超过 mRightMenuWidth 的一半儿,
就会显示右侧菜单,反之,隐藏。
那滑动距离又是怎么计算的?
我们重写 dispatchTouchEvent() 方法:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mFirstX = ev.getRawX();
mLastX = ev.getRawX();
break;
case MotionEvent.ACTION_MOVE:
float swipeDistance = mLastX - ev.getRawX();
scrollBy((int) swipeDistance, 0);
mLastX = ev.getRawX();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
if (Math.abs(getScrollX()) > mLimitDistance){
// TODO showMenu();
} else {
// TODO closeMenu();
}
break;
}
return super.dispatchTouchEvent(ev);
}
我们手指在不断左滑的时候,布局内容也会不断的向左移动。
很明显,这里是一个相对运动的过程。
所以我们在通过计算得到 swipeDistance 之后,调用 scrollBy();
接下来解释滑动距离:
通过代码可以看到,滑动距离是通过 getScrollX() 计算得到,
那么可能有朋友会问,
getScrollX() 表示什么?为什么还要用 Math.abs(getScrollX())?
getScrollX() 得到的 mScrollX,表示的是:
View 左边缘与 View 内容左边缘在水平方向的距离。
这么说可能太晦涩,我用几张图表示一下,你们就会明白了:
将黑色框向左移动,如下图所示:
将黑色框再向右移动,如下图所示:
看完图片,应该很好理解了吧?
那么现在判断滑动距离了,如何展示出来呢?
在这里我们用的是属性动画 ValueAnimator。
mShowAnim = ValueAnimator.ofInt(getScrollX(), mRightMenuWidth);
mShowAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
// 只需要执行一次,是一个绝对运动,所以调用 scrollTo();
scrollTo((Integer) animation.getAnimatedValue(), 0);
}
});
mShowAnim.setInterpolator(new OvershootInterpolator());
mShowAnim.setDuration(300).start();
ok,运行demo,看下效果:
那么,到目前为止,我们实现了左滑删除按钮的显示。但也有一些问题,
当手指不断左滑或者右滑的时候哦, view 也会不断的向左向右拉伸,
这样看起来,交互效果会很不舒服。那么如何来优化呢?
既然是在滑动的时候,产生的问题,那我们就在滑动的时候进行优化。
”哪里不会点哪里“
具体代码实现:
case MotionEvent.ACTION_MOVE:
float swipeDistance = mLastX - ev.getRawX();
scrollBy((int) swipeDistance, 0);
// 左右越界修正
if (getScrollX() < 0) {
scrollTo(0, 0);
}
if (getScrollX() > mRightMenuWidth) {
scrollTo(mRightMenuWidth, 0);
}
mLastX = ev.getRawX();
break;
再次运行,查看效果:
这次比上次已经流畅很多,而且用户也提升了不少。
但也有新的问题,当删除菜单展示,点击左侧内容区时,删除菜单并未收起隐藏。
那这个功能又如何实现?
首先定义一个变量 isContentDown,来表示手指按下的是否是内容区,默认为 true
我们知道当手指在屏幕进行操作的时候,如何来判断手指是点击内容区还是滑动呢?
这里我们需要定义一个变量,这个变量表示最小滑动距离,
不同的设备可能会有所不同。只有滑动距离超过了这个值,
系统才会认为你在做滑动操作。可以用四个字简单概括:滑动过滤
具体代码:
// 获取方法固定
mScaledTouchSlop = ViewConfiguration.get(mContext).getScaledTouchSlop();
dispatchTouchEvent() 方法中:
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
isContentDown = true;
break;
case MotionEvent.ACTION_MOVE:
float swipeDistance = mLastX - ev.getRawX();
// 判断如果滑动距离大于了 mScaledTouchSlop,则认为用户是在滑动
if (Math.abs(swipeDistance) > mScaledTouchSlop) {
isContentDown = false;
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
break;
default:
break;
}
这里我们重写 onInterceptTouchEvent() :
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_UP:
if (getScrollX() > mScaledTouchSlop) {
// 判断点击的是右侧删除按钮还是左侧内容区
if (ev.getX() < (getWidth() - getScrollX())) {
if (isContentDown) {
showMenu(false);
}
// 事件不再继续向下传递
return true;
}
}
break;
case MotionEvent.ACTION_CANCEL:
break;
}
return super.onInterceptTouchEvent(ev);
}
代码很简单也很清晰,不再做其他解释。现在运行,再看效果图:
现在大体功能已经实现的差不多了,但是还有一点欠缺。我们在刚开始的时候,定义了变量
mLimitDistance,但现在滑动距离即使大于 mLimitDistance,
右侧菜单也会隐藏,原因在哪儿?
原因在于我们之前添加了这一行代码:
if (Math.abs(swipeDistance) > mScaledTouchSlop) {
isContentDown = false;
}
当手指缓慢左右滑动的时候,isContentDown 始终是false。
所以在 onInterceptTouchEvent() 中会执行 showMenu(false)。
那么如何解决呢?解决办法:
在 onInterceptTouchEvent() 中,move 的时候添加:
if (Math.abs(ev.getRawX() - mFirstX) > mScaledTouchSlop){
return true;
}
这行代码表示,刚才手指在移动,事件不需要向下面传递。所以 up 中的代码也不会执行。
在实际应用中,我们可能会快速左滑或右滑,作为用户来说,
一般希望右侧菜单也会展示或隐藏,那怎么做呢?
很简单,通过得到手指快速滑动时某一点的速度,然后判断速度是否满足条件,
来进行右侧菜单的展示或者隐藏。
关键代码如下:
mTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
float velocityX = mTracker.getXVelocity(mPointerId);
if (Math.abs(velocityX) > 1000) {
if (velocityX < -1000) {
showMenu(true);
} else {
showMenu(false);
}
} else {
if (Math.abs(getScrollX()) > mLimitDistance) {
showMenu(true);
} else {
showMenu(false);
}
}
releaseTracker();
/**
* 及时释放
*/
private void releaseTracker() {
if (mTracker != null) {
mTracker.clear();
mTracker.recycle();
mTracker = null;
}
}
现在运行,看效果图:
又有一个新的问题产生了,想要的效果是一个列表中,
不允许多个删除按钮同时显示,那怎么做?
也很简单,要想解决问题,就要对症下药。
我们在删除按钮显示的时候,就把当前的 SwipeDeleteMenu 存储起来,
隐藏时,就置为空。
然后在down的时候,判断存储的对象是否为空,
如果不为空,就把它的删除按钮隐藏并置空。
关键代码:
if (sMenu != null) {
if (sMenu != this) {
sMenu.showMenu(false);
}
}
好了,关于侧滑删除的所有逻辑都已经编写完了,是不是感觉很简单?
其实就是很简单,只要一点点的拆析出来,就会很好做了。
最后附上源码地址:
SwipeDeleteMenu