Android 学习笔记 自定义控件之侧滑菜单


  • 这是自定义控件的第二篇学习笔记,侧滑菜单,也叫抽屉菜单,在大多数的应用中都有用到,而侧滑的滑字很关键,说白了就是移动;移动谁呢,自定义控件当然是移动View,移动View有几种方法:
    • 1.通过改变view在父View的layout位置来移动,但是只能移动指定的View:
      view.layout(l,t,r,b);
      view.offsetLeftAndRight(offset);//同时改变left和right
      view.offsetTopAndBottom(offset);//同时改变top和bottom
    • 2.通过改变scrollX和scrollY来移动,但是可以移动所有的子View;
      scrollTo(x,y);
      scrollBy(xOffset,yOffset);
    • 3.通过改变Canvas绘制的位置来移动View的内容:
      canvas.drawBitmap(bitmap, left, top, paint)

  • 本文所写的自定义控件没有用到上面的移动方法,而是使用ViewDragHelper这个类来处理移动。
    • 该类是谷歌在2013年开发者大会上提出的,谷歌能在开发者大会上提出一个类,想必这个类一定非常的强大,他主要用于封装对View的触摸位置,触摸速度,移动距离等的检测,并通过接口回调的方式告诉调用者,处理ViewGroup中子View的拖拽处理,该类的本质是一个对触摸事件的解析类。
  • 对于ViewDragHelper的使用首先要知道他是在高版本的V4包中(Android 4.4以上的V4包中),其次要明白我们需要用到哪些回调方法:
    • 首先是 boolean tryCaptureView(View child, int pointerId),它用于判断是否捕获当前子View的触摸事件,返回值true:就捕获并解析 false:不处理
  • int getViewHorizontalDragRange(View child),获取view水平方向的拖拽范围,返回的值用在手指抬起的时候view缓慢移动的动画计算上面,最好不要返回0
  • int clampViewPositionHorizontal(View child, int left, int dx),控制子View在水平方向的移动,可以在该方法中控制子View的移动范围,left 表示ViewDragHelper认为你想让当前子View的left改变的值(left=child.getLeft()+dx),dx 表示子View水平方向移动的距离.
  • onViewPositionChanged(View changedView, int left, int top, int dx, int dy),表示当子View的位置改变的时候执行,一般用来做其他子View的伴随移动
  • onViewReleased(View releasedChild, float xvel, float yvel),手指抬起的执行该方法,xvel:表示x方向的移动的速度 正:向右移动, 负:向左移动; yvel: 同理表示y方向移动的速度 正:向上移动, 负:向下移动

下里面来一发控件效果图:

主界面(侧滑菜单关闭).png
主界面和侧滑菜单(侧滑菜单打开).png

看完效果,开始撸这个控件:

  • 自定义控件,自定义View中有子View一般都是继承ViewGroup,但是我们这个自定义控件对于子View摆放位置没有特殊的需求,本质就是将两个子View叠放在一起,这时候我们就不必要去ViewGroup,然后又去重写OnMessure()方法测量,onLayout()摆放这么麻烦,直接继承系统已有的控件FrameLayout就可以帮我把事情给做好了。

  • 继承FrameLayout,添加必要的构造方法,首先重写onFinishInflate()方法,在该方法中获取两个子View对象,并初始化ViewDragHelper,并且将事件的拦截处理移交给ViewDragHelper,该控件只能有两个子View,一个作为侧边栏菜单页,一个为主界面,不等于两个则报出异常,

/**
 * Created by 毛麒添 on 2017/2/23 0023.
 * 自定义侧滑菜单控件
 */

public class MySlideMenu extends FrameLayout {

    private View leftMenu;//左边栏对象
    private View mainMenu;//主界面对象
    private FloatEvaluator floatEvaluator;//浮点数计算器
    private IntEvaluator intEvaluator;//整数计算器
    private ViewDragHelper viewDragHelper;

    public MySlideMenu(Context context) {
        super(context);
        init();
    }

    public MySlideMenu(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    public MySlideMenu(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    private void init(){
        viewDragHelper=ViewDragHelper.create(this,callback);
        floatEvaluator=new FloatEvaluator();
        intEvaluator=new IntEvaluator();
    }

@Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        //检测控件异常
        if(getChildCount()!=2){
            throw new IllegalArgumentException("MySlideMenu only have two childView!");
        }
        leftMenu = getChildAt(0);
        mainMenu = getChildAt(1);
    }

 @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        //让viewDragHelper帮助我们判断是否拦截
        return viewDragHelper.shouldInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //让触摸事件交给viewDragHelper来处理
        viewDragHelper.processTouchEvent(event);
        return true;
    }

 private ViewDragHelper.Callback  callback=new ViewDragHelper.Callback() {
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return false;

        }

        @Override
        public int getViewHorizontalDragRange(View child) {
            return super.getViewHorizontalDragRange(child);
        }

        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            return super.clampViewPositionHorizontal(child, left, dx);
        }

        @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            super.onViewPositionChanged(changedView, left, top, dx, dy);
        }

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            super.onViewReleased(releasedChild, xvel, yvel);
        }
    };
}
  • 接下来首先需要在boolean tryCaptureView(View child, int pointerId)方法中设置捕获两个子view的触摸事件,并设置侧滑菜单的拖拽范围
    private int width;//控件宽度
    private float dragRange;//拖拽范围
    
    /**
     * 该方法在onMeasure执行完成后执行,可以在该方法中初始化自己和子View的宽高
     */
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        width = getMeasuredWidth();
        dragRange = width*0.6f;
    }

   private ViewDragHelper.Callback callback=new ViewDragHelper.Callback() {
      @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return child==leftMenu||child==mainMenu;
        }
        @Override
        public int getViewHorizontalDragRange(View child) {
            return (int) dragRange;
        }
};
  • 这样ViewDragHelper已经可以获取两个子View的触摸事件,则要让触摸可以移动,则应该在clampViewPositionHorizontal(View child, int left, int dx)方法中返回left,前面已经解释过,该返回值就是你真正想让子View移动的距离,但是这时候返回这个值还不够,你需要做出限制,不让他可以完全移动出屏幕,具体思想就是当你的触摸的是主界面的子View时候,该子View的左边left小于0的时候,说明这是已经跑出左边界,则强制等于0,同理右边大于主界面的可以拖拽范围的时候,则强制等于最大拖拽范围;然后是onViewPositionChanged(View changedView, int left, int top, int dx, int dy),该方法做子View的伴随移动,当我们移动侧滑菜单子View的时候,希望可以拖动侧滑菜单也能让主界面的子View伴随侧滑菜单一起移动,这样才能显示出侧滑效果,要不然只有移动主界面才能侧滑就不够生动;该方法的思想为当时移动侧滑菜单的时候,侧滑菜单固定,并在同第一个方法的限制方位逻辑下让主界面子View的位置伴随侧滑面板一起移动,说了一大段,还是上代码实在,将这两个方法改造成:
private ViewDragHelper.Callback callback=new ViewDragHelper.Callback() {
 @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            if(child==mainMenu){
                if(left<0) left=0;//限制左边界
                if(left>dragRange)left= (int) dragRange;//限制右边界
            }
            return left;
        }

      
        @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
           if(changedView==leftMenu){
               //固定侧滑菜单
               leftMenu.layout(0,0,mainMenu.getMeasuredWidth(),mainMenu.getMeasuredHeight());
               int newLeft=mainMenu.getLeft()+dx;
               if(newLeft<0) newLeft=0;//限制左边界
               if(newLeft>dragRange) newLeft= (int) dragRange;//限制右边界
               //两个菜单一起伴随滑动
               mainMenu.layout(newLeft,mainMenu.getTop()+dy,mainMenu.getRight()+dx,mainMenu.getBottom()+dy);
           }
};
  • 到此该侧滑菜单控件已经初步成型,但是这个侧滑菜单还是太生硬了,拉出或者关闭侧滑菜单不顺滑,这时候可以使用Scroller来是移动顺滑,但是ViewDragHelper就是这么强大,他已经将Scroller集成好,我么直接使用就可以,这时思想为当主界面的移动范围小于拖拽的范围的二分之一,则侧滑菜单自动关闭,大于二分之一,则自动打开,而这些都是手指抬起后的动作,所以在 onViewReleased(View releasedChild, float xvel, float yvel)方法做处理:
 private ViewDragHelper.Callback callback=new ViewDragHelper.Callback() {
@Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
           if(mainMenu.getLeft()<dragRange/2){//在拖拽范围的左边,关闭
               close();
           }else {//在拖拽范围的右边,打开
               open();
           }
       }
};
//侧滑面板打开
    public void open() {
        viewDragHelper.smoothSlideViewTo(mainMenu, (int) dragRange,mainMenu.getTop());
        ViewCompat.postInvalidateOnAnimation(MySlideMenu.this);//刷新
    }
    //侧滑面板关闭
    public void close() {
        viewDragHelper.smoothSlideViewTo(mainMenu,0,mainMenu.getTop());
        ViewCompat.postInvalidateOnAnimation(MySlideMenu.this);//刷新
    }

@Override
    public void computeScroll() {
        if(viewDragHelper.continueSettling(true)){
            ViewCompat.postInvalidateOnAnimation(MySlideMenu.this);//刷新
        }
    }
  • 到此,侧滑面板大概样子已经初步完成,但是为了向抽屉更加形象,则可以在拖拽的过程中加入动画效果,我们可以这样,可以计算出拖拽过程的程度除与最大拖拽长度,就可以得出百分比,根据这个百分比来执行伴随动画,对,就是这样,因为onViewPositionChanged(View changedView, int left, int top, int dx, int dy)是在View位置变化的时候执行的方法,所以继续对其改造,并加入放大,透明,遮罩等动画:
 @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
           if(changedView==leftMenu){
               //固定侧滑菜单
               leftMenu.layout(0,0,mainMenu.getMeasuredWidth(),mainMenu.getMeasuredHeight());
               int newLeft=mainMenu.getLeft()+dx;
               if(newLeft<0) newLeft=0;//限制左边界
               if(newLeft>dragRange) newLeft= (int) dragRange;//限制右边界
               //两个菜单一起伴随滑动
               mainMenu.layout(newLeft,mainMenu.getTop()+dy,mainMenu.getRight()+dx,mainMenu.getBottom()+dy);
           }
            //计算滑动百分比
            float fraction=mainMenu.getLeft()/dragRange;
            //执行伴随动画
            executeAnim(fraction);
}

private void executeAnim(float fraction) {
        //移动侧边栏
        ViewHelper.setTranslationX(leftMenu,intEvaluator.evaluate(fraction,-leftMenu.getMeasuredWidth()/2,0));
        //放大侧边栏
        ViewHelper.setScaleX(leftMenu,floatEvaluator.evaluate(fraction,0.5f,1f));
        ViewHelper.setScaleY(leftMenu,floatEvaluator.evaluate(fraction,0.5f,1f));
        //改变侧边栏的透明度
        ViewHelper.setAlpha(leftMenu,floatEvaluator.evaluate(fraction,0.3f,1f));
        //给侧边栏背景添加黑色遮罩效果
        getBackground().setColorFilter((Integer) ColorUtil.evaluateColor(fraction, Color.BLACK,Color.TRANSPARENT), PorterDuff.Mode.SRC_OVER);
    }
  • ViewHelper是在一个版本兼容包中,找不到可以下载我的源码拿(源码地址在最下面):
ViewHelper所在兼容包.png
  • 接下里就是设置外部监听回调,回调说白了就是空间内部发生的事情需要让使用者知道,比如你是老板,吩咐员工去外地办事,员工在外地办好事打电话给你,就相当于回调(感觉这个例子很抠脚);回调接口定义步骤一般为:
    • 1.定义一个回调接口,在接口中定义为实现的逻辑方法
    • 2.传递一个实现了此接口类的对象,并且实现上述接口中未实现的方法
    • 3.在需要告知外部的地方调用接口中需要告知的方法
/**
     * 设置外部监听回调
     */
    private onDragStateChangeListener listener;

    public void setOnDragStateChangeListener(onDragStateChangeListener listener){
        this.listener=listener;
    }

    public interface onDragStateChangeListener{
        /**
         * 侧滑菜单打开
         */
        void onOpen();

        /**
         * 侧滑菜单处于关闭
         */
        void onClose();

        /**
         *正在拖拽,将此时的百分比随时暴露给调用者
         */
        void Draging(float fraction);
    }
  • 定义好回调接口,则在需要告知外部的地方使用逻辑方法,也就是在滑动拖拽的过程中根据拖拽的百分比来确定侧滑菜单是打开或者关闭,这时候就可以枚举出两个打开或者关闭的状态,方便操作;所以,再次改造onViewPositionChanged(View changedView, int left, int top, int dx, int dy)方法:
private DragState currentState=DragState.STATE_CLOSE;//默认是关闭状态

    /**
     * 枚举侧滑菜单的开关状态
     */
    public enum DragState{
        STATE_OPEN,STATE_CLOSE
    }

/**
     * 获取侧滑菜单状态
     */
    public DragState getDragState(){
        return currentState;
    }

@Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
           if(changedView==leftMenu){
               //固定侧滑菜单
               leftMenu.layout(0,0,mainMenu.getMeasuredWidth(),mainMenu.getMeasuredHeight());
               int newLeft=mainMenu.getLeft()+dx;
               if(newLeft<0) newLeft=0;//限制左边界
               if(newLeft>dragRange) newLeft= (int) dragRange;//限制右边界
               //两个菜单一起伴随滑动
               mainMenu.layout(newLeft,mainMenu.getTop()+dy,mainMenu.getRight()+dx,mainMenu.getBottom()+dy);
           }
            //计算滑动百分比
            float fraction=mainMenu.getLeft()/dragRange;
            //执行伴随动画
            executeAnim(fraction);
            //根据百分比来值来确定侧滑菜单是打开还是关闭
            if(fraction==0&&currentState!=DragState.STATE_CLOSE){//如果百分比是0,且当前状态不是关闭
                currentState=DragState.STATE_CLOSE;
                //调用回调方法
                listener.onClose();
            }else if(fraction==1&&currentState!=DragState.STATE_OPEN){
                currentState=DragState.STATE_OPEN;
                //调用回调方法
                listener.onOpen();
            }
            if(listener!=null){
                //只要listener存在,就将百分比暴露出去
                listener.Draging(fraction);
            }
        }

打这里,侧滑菜单控件类的大部分已经完成,但是,还差一个小问题,那就是这时候的侧滑菜单拖动打开或者关闭一定要大于或者小于拖动范围的二分之一才能打开或者关闭,而成熟应用的的侧滑菜单都是手指一划就可以打开或者关闭,其实这是根据手指滑动的速度来做,上面介绍方法的时候已介绍过onViewReleased(View releasedChild, float xvel, float yvel)方法可以获取X轴和Y轴的速度,所以将其改造为:

@Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
           if(mainMenu.getLeft()<dragRange/2){//在拖拽范围的左边,关闭
               close();
           }else {//在拖拽范围的右边,打开
               open();
           }
            //当用户稍微滑动一下,根据X轴方向的速度来打开或者关闭侧滑菜单
            if(xvel>200&&currentState!=DragState.STATE_OPEN){
                open();
            }else if(xvel<-200&&currentState!=DragState.STATE_CLOSE){
                close();
            }
       }

到此,整个自定义控件侧滑菜单类已经完成,但是还有一些小瑕疵,当侧滑面侧滑面板是打开状态下,发现主界面的ListView还是可以滑动,也就是说侧滑菜单打开的时候主界面的点击事件没有被拦截,而主界面子View我使用根布局是LinerLayout,所有可以自定义一个LinerLayout,让其在侧滑菜单是打开的状态下拦截事件并消费掉就可以了(事件分发拦截机制这里不多说),并且在打开的状态下点击主界面就可以关闭侧滑菜单,逻辑很简单:

/**
 * Created by 毛麒添 on 2017/2/24 0024.
 * 当自定的侧滑菜单打开的时候,右侧的主界面菜单不应该能滑动,
 * 自定义一个LinearLayout拦截并消费该触摸事件
 */

public class MyLinerLayout extends LinearLayout {

    private MySlideMenu mySlideMenu;

    public MyLinerLayout(Context context) {
        super(context);
    }

    public MyLinerLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public MyLinerLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public void setSlideMenu(MySlideMenu mySlideMenu){
        this.mySlideMenu=mySlideMenu;
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if(mySlideMenu!=null&& mySlideMenu.getDragState()== MySlideMenu.DragState.STATE_OPEN){
            //如果该侧滑面板是打开,则拦截消费触摸事件
           return true;
        }
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if(mySlideMenu!=null&& mySlideMenu.getDragState()== MySlideMenu.DragState.STATE_OPEN){
            if(event.getAction()==MotionEvent.ACTION_UP){//在侧滑面板打开的状态时候点一下主界面应该关闭侧滑面板
                mySlideMenu.close();
            }
            //如果该侧滑面板是打开,则拦截消费触摸事件
            return true;
        }
        return super.onTouchEvent(event);
    }
}

好了,扯了一大堆,总算了是把这个自定义控件完成了,布局和MainActivity比较简单,这里就不贴了。
整体一步一步走下来,还是能对技术有不少提升的。如果有错误,希望大家可以给我提出来,大家一起学习进步。

源码下载地址:https://github.com/maoqitian/MySlideMenu

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

推荐阅读更多精彩内容