Android View 层级实现仿 iOS 左边缘右滑关闭页面

前言

之前在实现功能需求的时候,遇到过一些知识点和难点,但在解决了之后,自己并没有详细的记录下来,而是仅仅简单的收藏起关键的代码,准备以后需要的时候可以用到。但是过了一段时间之后,当需要再次实现这个功能点的时候,之前那些知识已经忘记的差不多了,所以不得不再次查找资料进行二次学习,现在将其总结起来,方便自己,也分享给需要的人,如果有哪些是错误的,欢迎指正。

需求

前段时间在开发公司项目 V1.0.3 版本的时候,设计师提了个需求,在打开设置页的某项二级页面时,能否像 iOS 那样,打开的时候,二级页面是从右边出现,然后滑到左边的边缘。并且手指在左边的边缘可以滑动这个二级页面,快速滑动的时候,可以关闭这个二级页面。当二级页面滑到中间手指抬起,如果滑动距离超过一半,则关闭二级页面,否则让二级页面回到左边缘。当完全打开二级页面时,按手机 Back 键,需要从左到右滑动页面,然后关闭。

需求分析

首先,分析以上的需求,再结合项目已有的代码布局情况,然后总结,提炼出我们需要完成的功能点。因为项目中的设置页布局最外层是一个 FrameLayout,所以首先想到的就是我们自定义的View 基础自 FrameLayout,这样可以方便的加上之前的项目代码。需求的功能点如下:

  • 打开二级页面时,是从右边滑动到左边缘
  • 手指在左边的边缘,可以滑动二级页面,并且快速滑动的时候,可以关闭这个页面
  • 滑动距离未超过父布局的一半,手指抬起需要让二级页面回到左边缘,否则关闭页面
  • 按下手机的 Back 键,也能动画关闭页面
效果截屏

技术实现

以上为此次自定义 View 的实现效果,下面介绍使用方法以及源码分析

  • 使用步骤

    1. 触发跳转到二级页面时,将二级页面 add 到布局中,假设在 Demo 中,二级页面 为 secondView


    2. 在 Activity 的后退回调方法中,判断当前 secondView 是否以及显示,是的话,将其移除。



      以上两个步骤就是 SliderCloseView 的使用方法,非常简单。

  • 源码分析

  1. 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);
        }

    }
  1. 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 这种情况。
最后,在最后一个手指抬起时,判断水平方向上的速度是否超过了限定的阈值,超过的话,开启动画关闭页面。否则根据已经滑动的距离,判断是否关闭页面。

  1. 接口回调
public interface OnSliderListener{
        //判断打开的进度
        void onProgress(int current, float progress,View view);
        //页面关闭
        void onSliderHidden();
        //页面打开
        void onSliderShow(View  page);
    }

总结

由于知识水平有限,难免有错误,欢迎指正,最后附上项目代码地址。
https://github.com/hanilala/CoolCode

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,761评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,953评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,998评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,248评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,130评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,145评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,550评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,236评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,510评论 1 291
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,601评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,376评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,247评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,613评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,911评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,191评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,532评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,739评论 2 335

推荐阅读更多精彩内容