前言
之前在实现功能需求的时候,遇到过一些知识点和难点,但在解决了之后,自己并没有详细的记录下来,而是仅仅简单的收藏起关键的代码,准备以后需要的时候可以用到。但是过了一段时间之后,当需要再次实现这个功能点的时候,之前那些知识已经忘记的差不多了,所以不得不再次查找资料进行二次学习,现在将其总结起来,方便自己,也分享给需要的人,如果有哪些是错误的,欢迎指正。
需求
前段时间在开发公司项目 V1.0.3 版本的时候,设计师提了个需求,在打开设置页的某项二级页面时,能否像 iOS 那样,打开的时候,二级页面是从右边出现,然后滑到左边的边缘。并且手指在左边的边缘可以滑动这个二级页面,快速滑动的时候,可以关闭这个二级页面。当二级页面滑到中间手指抬起,如果滑动距离超过一半,则关闭二级页面,否则让二级页面回到左边缘。当完全打开二级页面时,按手机 Back 键,需要从左到右滑动页面,然后关闭。
需求分析
首先,分析以上的需求,再结合项目已有的代码布局情况,然后总结,提炼出我们需要完成的功能点。因为项目中的设置页布局最外层是一个 FrameLayout,所以首先想到的就是我们自定义的View 基础自 FrameLayout,这样可以方便的加上之前的项目代码。需求的功能点如下:
- 打开二级页面时,是从右边滑动到左边缘
- 手指在左边的边缘,可以滑动二级页面,并且快速滑动的时候,可以关闭这个页面
- 滑动距离未超过父布局的一半,手指抬起需要让二级页面回到左边缘,否则关闭页面
- 按下手机的 Back 键,也能动画关闭页面
技术实现
以上为此次自定义 View 的实现效果,下面介绍使用方法以及源码分析
-
使用步骤
-
触发跳转到二级页面时,将二级页面 add 到布局中,假设在 Demo 中,二级页面 为 secondView
-
在 Activity 的后退回调方法中,判断当前 secondView 是否以及显示,是的话,将其移除。
以上两个步骤就是 SliderCloseView 的使用方法,非常简单。
-
源码分析
- onInterceptTouchEvent
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = MotionEventCompat.getActionMasked(ev);
switch (action){
case MotionEvent.ACTION_DOWN:{
//Down 事件触发时,表示有第一个手指接触到屏幕了
//获取第一个手指Down 的PointerId
mActivePointerId = ev.getPointerId(0);
mInitDownX = getMotionEventX(ev);
// mLastDownX = mInitDownX;
mInitDownY = getMotionEventY(ev);
if(mInitDownX == INVALID_VALUE || mInitDownY == INVALID_VALUE){
mIsBeingDrag = false;
return super.onInterceptTouchEvent(ev);
}
break;
}
case MotionEvent.ACTION_MOVE:{
float x = getMotionEventX(ev);
float y = getMotionEventY(ev);
float diffX = x - mInitDownX;
float diffY = y - mInitDownY;
//手指按下的初始位置在屏幕左侧的 十分之一的范围里,并且 X 方向的距离
//比 Y 方向上的多,也超过最小的 mTouchSlop,就可以认为已经开始拖拽了
if( mInitDownX < getWidth() / 10 && Math.abs(diffX) >= mTouchSlop
&& Math.abs(diffX) > Math.abs(diffY)){
mIsBeingDrag = true;
}
break;
}
case MotionEvent.ACTION_POINTER_UP:{
//当有多个手指按在屏幕上,其中一个手指抬起时会进入此方法
onSecondaryPointerUp(ev);
break;
}
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:{
//最后一个手指抬起,或者事件被父view 拦截时,恢复到初始状态
mIsBeingDrag = false;
mInitDownX = 0;
mInitDownY = 0;
// mLastDownX = 0;
mActivePointerId = MotionEvent.INVALID_POINTER_ID;
break;
}
}
//如果 mIsBeingDrag 为 true ,说明已经触发了滑动的条件
//事件会被拦截,交给 onTouchEvent 处理
return mIsBeingDrag || super.onInterceptTouchEvent(ev);
}
首先,我们在第一个手指按下的时候,记录其 PointerId 值,和最初的 X,Y值。然后在手指移动的过程中,也就是 Move 事件中判断,如果手指按下的初始位置在屏幕左侧的 十分之一的范围里,并且 X 方向的距离比 Y 方向上的多,也超过最小的 mTouchSlop,就可以认为已经开始拖拽了,mIsBeingDrag 被设置为 true, 方法结束的时候返回 mIsBeingDrag ,代表我们拦截了此次事件。
需要注意的是,我们在 onInterceptTouchEvent 方法中加多了 MotionEvent.ACTION_POINTER_UP 这个判断,因为我们设置第一个按下的手指的 PointerId 为 mActivePointerId,所以考虑到有可能在按下第一个手指的时候并未触发滑动,而是接着按下第二、第三...个手指,但是在抬起手指的情况下,有可能会抬起第一个手指,所以这里加多了这种情况的处理。
/**
* 当屏幕上有手指抬起时,判断是不是 Down 事件触发时记录的 PointerId
* 如果是的话,选其他手指的 PointerId 作为 mActivePointerId
* @param event
*/
private void onSecondaryPointerUp(MotionEvent event){
int pointerIndex = MotionEventCompat.getActionIndex(event);
int pointerId = event.getPointerId(pointerIndex);
if(pointerId == mActivePointerId){
int newPointerIndex = pointerIndex == 0 ? 1: 0;
mActivePointerId = event.getPointerId(newPointerIndex);
}
}
-
onTouchEvent
当 onInterceptTouchEvent 返回 true 的情况下,事件被拦截,交由 onTouchEvent 处理
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (MotionEventCompat.getActionMasked(event)){
case MotionEvent.ACTION_DOWN:{
mInitDownX = getMotionEventX(event);
mInitDownY = getMotionEventY(event);
break;
}
case MotionEvent.ACTION_MOVE:{
//初始化速度追踪器,用以追踪手指的滑动速度
if(mVelocityTracker == null){
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);
float x = getMotionEventX(event);
float diffX = x - mInitDownX;
if( diffX >= 0 ){
//手指是向右滑动的,偏移 SliderView
if(mSliderView != null){
mSliderView.setTranslationX(diffX);
}
}
if(mSliderListener != null){
mSliderListener.onProgress((int) diffX,diffX * 1.0f / getWidth(),mSliderView);
}
Log.w("lala","getScrollX: "+diffX+" rate: "+ diffX * 1.0f / getWidth() );
// 左侧即将滑出屏幕
return true;
}
case MotionEvent.ACTION_POINTER_UP:
//当有多个手指按在屏幕上,其中一个手指抬起时会进入此方法
onSecondaryPointerUp(event);
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:{
if(mVelocityTracker != null && mActivePointerId != MotionEvent.INVALID_POINTER_ID){
//获取手指抬起的一瞬间,获取 X 方向上的速度
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity(mActivePointerId);
Log.w("tracker","X velocity: "+xVelocity);
mVelocityTracker.clear();
mVelocityTracker = null;
if( xVelocity >= HORIZANTAL_SPEED && mSliderView != null){
//如果水平的速度超过了特定值,可以认为是手指 fling 操作
//让 sliderview 做向右的动画操作,关闭页面
mCurTranslationX = mSliderView.getTranslationX();
actionEnd(true);
break;
}
}
// 根据手指释放时的位置决定回弹还是关闭
float x = getMotionEventX(event);
float diffX = x - mInitDownX;
if( diffX == 0 ){
//手指滑动了 sliderview,但是最后手指抬起时,让它回到了原来的位置
if(mSliderListener != null){
mSliderListener.onSliderShow(mSliderView);
}
resetValue();
} else if( diffX == getWidth()){
if(mSliderListener != null){
mSliderListener.onSliderHidden();
}
resetValue();
} else {
if (mSliderView != null ){
mCurTranslationX = mSliderView.getTranslationX();
//sliderview 在 水平方向的偏移少于父布局的宽度的一半
//则让其回到原位,否则做动画打开
if(mCurTranslationX < getWidth() / 2){
actionEnd(false);
}
else {
actionEnd(true);
}
}
}
break;
}
}
return super.onTouchEvent(event);
}
事件拦截后,我们在 ACTION_MOVE 里判断手指是不是从左边滑向右边,是的话,让 view 往右边偏移,防止将其滑出屏幕。同样有可能出现的一种情况是,我们在用第一个手指滑动 view ,然后接着按下第二、第三...个手指,接着又抬起手指,所以同样的这里也要考虑 ACTION_POINTER_UP 这种情况。
最后,在最后一个手指抬起时,判断水平方向上的速度是否超过了限定的阈值,超过的话,开启动画关闭页面。否则根据已经滑动的距离,判断是否关闭页面。
- 接口回调
public interface OnSliderListener{
//判断打开的进度
void onProgress(int current, float progress,View view);
//页面关闭
void onSliderHidden();
//页面打开
void onSliderShow(View page);
}
总结
由于知识水平有限,难免有错误,欢迎指正,最后附上项目代码地址。
https://github.com/hanilala/CoolCode