美文网首页AndroidWorld自定义控件Android Community
自定义控件?试试300行代码实现QQ侧滑菜单

自定义控件?试试300行代码实现QQ侧滑菜单

作者: cv大法师 | 来源:发表于2017-01-13 10:38 被阅读429次

    Android自定义控件并没有什么捷径可走,需要不断得模仿练习才能出师。这其中进行模仿练习的demo的选择是至关重要的,最优选择莫过于官方的控件了,但是官方控件动辄就是几千行代码往往可能容易让人望而却步。本文介绍如何理解并实现Android端的QQ侧滑菜单,300行代码即可。
    首先上完成的效果图:


    侧滑效果侧滑效果

    大家可以对比自己手机上QQ的侧滑菜单,效果与之几乎没有什么差别。

    首先

    本文并不会长篇大论的讲解自定义控件所需要的从绘图、屏幕坐标系、滑动到动画等原理,因为我相信无论您是否会自定义控件,这些原理您都已经从别处烂熟于心了。但是为了方便理解,会在实现的过程中进行穿插讲解。

    确定目标及方向

    动手撸代码前,我们看一眼这个效果。首先确定我们的目标是需要自定义一个ViewGroup,需要控制它的两个子View进行滑动变换。进一步观察我们可以发现两个子View是叠加再一起的,所以为了减少代码我们可以考虑直接继承于ViewGroup的一个实现类:FrameLayout。底层的是菜单视图menu,叠加在上面的是主界面main
    新建一个类:CoordinatorMenu,并在加载布局后拿到两个子View

    public class CoordinatorMenu extends FrameLayout {
        private View mMenuView;
        private View mMainView;
        
        //加载完布局文件后调用
        @Override
        protected void onFinishInflate() {
            mMenuView = getChildAt(0);//第一个子View在底层,作为menu
            mMainView = getChildAt(1);//第二个子View在上层,作为main
        }
    

    为滑动做准备

    实现手指跟随滑动,这其中有很多方法,最基本的莫过于重写onTouchEvent方法并配合Scroller实现了,但是这也是最复杂的了。还好官方提供了一个ViewDragHelper类帮助我们去实现(本质上还是使用Scroller)。
    在我们的构造方法中通过ViewDragHelper静态方法进行其初始化:

    mViewDragHelper = ViewDragHelper.create(
        this, 
        TOUCH_SLOP_SENSITIVITY, 
        new CoordinatorCallback());
    

    三个参数的含义:

    • 需要监听的View,这里就是当前的控件
    • 开始触摸滑动的敏感度,值越大越敏感,1.0f是正常值
    • 一个Callback回调,整个ViewDragHelper的核心逻辑所在,这里自定义了一个它的实现类

    然后拦截触摸事件,交给我们的主角ViewDragHelper处理:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return mViewDragHelper.shouldInterceptTouchEvent(ev);
    }
    
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //将触摸事件传递给ViewDragHelper,此操作必不可少
        mViewDragHelper.processTouchEvent(event);
        return true;
    }
    

    处理computeScroll方法:

    //滑动过程中调用
    @Override
    public void computeScroll() {
        if (mViewDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);//处理刷新,实现平滑移动
        }
    }
    

    处理部分Callback回调

    //告诉ViewDragHelper对哪个子View进行拖动滑动
    @Override
    public boolean tryCaptureView(View child, int pointerId) {
        //侧滑菜单默认是关闭的
        //用户必定只能先触摸的到上层的主界面
        return mMainView == child;
    }
    
    //进行水平方向滑动
    @Override
    public int clampViewPositionHorizontal(View child, int left, int dx) {
        return left;//通常返回left即可,left指代此view的左边缘的位置
    }
    

    main的滑动

    这样我们就能在水平方向上随意拖动上层的子View--main了,接下来就是限制它水平滑动的范围了,范围如下图所示:

    菜单完全展开后main的位置菜单完全展开后main的位置
    改写上面的水平滑动方法,
    @Override
    public int clampViewPositionHorizontal(View child, int left, int dx) {
        if (left < 0) {
            left = 0;//初始位置是屏幕的左边缘
        } else if (left > mMenuWidth) {
            left = mMenuWidth;//最远的距离就是菜单栏完全展开后的menu的宽度
        }
        return left;    
    }
    

    增加回弹效果:

    • 当菜单关闭,从左向右滑动main的时候,小于一定距离松开手,需要让它回弹到最左边,否则直接打开菜单
    • 当菜单完全打开,从右向左滑动main的时候,小于一定距离松开手,需要让它回弹到最右边,否则直接关闭菜单

    首先判断滑动的方向:

    //当view位置改变时调用,也就是拖动的时候
    @Override
    public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
        //dx代表距离上一个滑动时间间隔后的滑动距离
        if (dx > 0) {//正
            mDragOrientation = LEFT_TO_RIGHT;//从左往右
        } else if (dx < 0) {//负
            mDragOrientation = RIGHT_TO_LEFT;//从右往左
        }
    }
    

    在松开手后:

    //View释放后调用
    @Override
    public void onViewReleased(View releasedChild, float xvel, float yvel) {
        super.onViewReleased(releasedChild, xvel, yvel);
        if (mDragOrientation == LEFT_TO_RIGHT) {//从左向右滑
            if (mMainView.getLeft() < mSpringBackDistance) {//小于设定的距离
                closeMenu();//关闭菜单
            } else {
                openMenu();//否则打开菜单
            }
        } else if (mDragOrientation == RIGHT_TO_LEFT) {//从右向左滑
            if (mMainView.getLeft() < mMenuWidth - mSpringBackDistance){//小于设定的距离
                closeMenu();//关闭菜单
            } else {
                openMenu();//否则打开菜单
            }
        }
    }
    
    public void openMenu() {
        mViewDragHelper.smoothSlideViewTo(mMainView, mMenuWidth, 0);
        ViewCompat.postInvalidateOnAnimation(CoordinatorMenu.this);
    }
    
    public void closeMenu() {
        mViewDragHelper.smoothSlideViewTo(mMainView, 0, 0);
        ViewCompat.postInvalidateOnAnimation(CoordinatorMenu.this);
    }
    

    menu的滑动

    展开后,我们就可以触摸到底层的menu视图了,我们拽menu不能拖动它本身,也不能拖动main,因为我们在前面指定了触摸只作用于main。我们可以先思考一下,QQ的侧滑菜单底层是跟随上层移动的(细心的您会发现不是完全跟随的,它们之间的距离变化有个线性关系,这个稍后再说),这样的话那我们就可以把menu完全托付给main处理,分两步:1.menu托付给main;2.main滑动时管理menu的滑动。
    首先我们要先确定menu的初始位置及大小,重写layout方法,向左偏移一个mMenuOffset

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        MarginLayoutParams menuParams = (MarginLayoutParams) mMenuView.getLayoutParams();
        menuParams.width = mMenuWidth;
        mMenuView.setLayoutParams(menuParams);
        mMenuView.layout(-mMenuOffset, top, mMenuWidth - mMenuOffset, bottom);
        }
    

    我们先实现第一步:触摸到menu,交给main处理。
    在这之前改写前面的回调方法,让menu能接受触摸事件

    @Override
    public boolean tryCaptureView(View child, int pointerId) {
        return mMainView == child || mMenuView == child;
    }
    

    然后

    //观察被触摸的view
    @Override
    public void onViewCaptured(View capturedChild, int activePointerId) {
        if (capturedChild == mMenuView) {//当触摸的view是menu
            mViewDragHelper.captureChildView(mMainView, activePointerId);//交给main处理
        }
    }
    

    在这一步后,我们就可以在手指触摸到menu的时候,拖动main
    这个感觉就像是指桑骂槐,指着的是menu,骂的却是main,哈哈。

    接下来我们实现第二步,menu跟随main滑动
    先看下面menumain的位置关系图


    很明显我们能得出一个结论:

    从menu关闭到menu的打开:menu移动了它的初始向左偏移距离mMenuOffset,main移动了的距离正好是menu的宽度mMenuWidth

    所以我们就可以用之前用到的回调:onViewPositionChanged(View changedView, int left, int top, int dx, int dy),因为这里的dx正是指代移动距离,只要main移动了一个dx,那我们就可以让menu移动一个dx * mMenuOffset / mMenuWidth,不就行了吗?
    看起来十分美好,实践起来却是No!No!No!,因为需要对menu使用layout方法进行重新布局以达到移动效果,而这个方法传进去的值是int型,而我们上面的计算公式的结果很明显是个float,况且很不巧的是这个dx是指代表距离上一个滑动时间间隔后的滑动距离,就是把你整个滑动过程分割成很多的小块,每一小块的时间很短,如果你滑动很慢的话,那么在这很短的时间内dx=1,呵呵。所以这样计算的话精度严重丢失,不能达到同步移动的效果。
    所以我们只能换一种思维,使用它们之间的另一种关系:menu左边缘和main左边缘之间的距离是由mMenuOffset增加到mMenuWidth,此时main移动了mMenuWidth。可以认为这种增加是线性的,如下图所示:


    根据图及公式y = kx + d得出:
    mainLeft - menuLeft = (mMenuWidth - mMenuOffset) / mMenuWidth * mainLeft 
    + mMenuOffset
    

    所以这样重写回调onViewPositionChanged即可使menu跟随main进行滑动变换:

    @Override
    public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
        float scale = (float) (mMenuWidth - mMenuOffset) / (float) mMenuWidth;
        int menuLeft = left - ((int) (scale * left) + mMenuOffset);
        mMenuView.layout(menuLeft, mMenuView.getTop(),
                menuLeft + mMenuWidth, mMenuView.getBottom());
    }
    

    相信如果我没有给出上面的数学关系解答,直接看代码,您可能会一脸懵逼,这也是很多自定义控件源码难读的原因。

    给main加个滑动渐变阴影

    经过上面的操作,感觉总体已经有了模样了,但还缺少一样东西,就是main经过菜单由关闭到完全打开的过程中,会有一层透明到不透明变化的阴影,看下面动图演示:

    阴影变化阴影变化
    实现这个功能我们需要知道ViewGroup通过调用其drawChild方法对子view按顺序分别进行绘制,所以在绘制完menumain后,我们需要绘制一层左边缘随main变化且上边缘、右边缘和下边缘不变的视图,而且这个视图的透明度也会变化。
    @Override
    protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
        boolean result = super.drawChild(canvas, child, drawingTime);//完成原有的子view:menu和main的绘制
    
        int shadowLeft = mMainView.getLeft();//阴影左边缘位置
        final Paint shadowPaint = new Paint();//阴影画笔
        shadowPaint.setColor(Color.parseColor("#" + mShadowOpacity + "777777"));//给画笔设置透明度变化的颜色
        shadowPaint.setStyle(Paint.Style.FILL);//设置画笔类型填充
        canvas.drawRect(shadowLeft, 0, mScreenWidth, mScreenHeight, shadowPaint);//画出阴影
    
        return result;
    }
    

    其中这个mShadowOpacity是随main的位置变化而变化的:

    private String mShadowOpacity = "00"
    
    @Override
    public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
        float showing = (float) (mScreenWidth - left) / (float) mScreenWidth;
        int hex = 255 - Math.round(showing * 255);
        if (hex < 16) {
            mShadowOpacity = "0" + Integer.toHexString(hex);
        } else {
            mShadowOpacity = Integer.toHexString(hex);
        }
    }
    

    至此我们的菜单可以说是完工了,but!

    还需要一些优化

    1.如果打开菜单,熄屏,再亮屏,此时菜单就又恢复到关闭的状态了,因为重新亮屏后,layout方法会重新调用,也就是说我们的子view会重新布局,所以要改写这个方法:

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        MarginLayoutParams menuParams = (MarginLayoutParams) mMenuView.getLayoutParams();
        menuParams.width = mMenuWidth;
        mMenuView.setLayoutParams(menuParams);
        if (mMenuState == MENU_OPENED) {//判断菜单的状态为打开的话
            //保持打开的位置
            mMenuView.layout(0, 0, mMenuWidth, bottom);
            mMainView.layout(mMenuWidth, 0, mMenuWidth + mScreenWidth, bottom);
            return;
        }
        mMenuView.layout(-mMenuOffset, top, mMenuWidth - mMenuOffset, bottom);
    }
    
    //获取菜单的状态
    @Override
    public void computeScroll() {
        if (mMainView.getLeft() == 0) {
            mMenuState = MENU_CLOSED;
        } else if (mMainView.getLeft() == mMenuWidth) {
            mMenuState = MENU_OPENED;
        }
    }
    

    2.旋转屏幕也会出现上述的问题,这时就需要调用onSaveInstanceStateonRestoreInstanceState这两个方法分别用来保存和恢复我们菜单的状态。

    protected static class SavedState extends AbsSavedState {
        int menuState;//记录菜单状态的值
    
        SavedState(Parcel in, ClassLoader loader) {
            super(in, loader);
            menuState = in.readInt();
        }
        
        @Override
        public void writeToParcel(Parcel dest, int flags) {
            super.writeToParcel(dest, flags);
            dest.writeInt(menuState);
        }
        ...
        ...
        ...
    }
    
    @Override
    protected Parcelable onSaveInstanceState() {
        final Parcelable superState = super.onSaveInstanceState();
        final CoordinatorMenu.SavedState ss = new CoordinatorMenu.SavedState(superState);
        ss.menuState = mMenuState;//保存状态
        return ss;
    }
    
    @Override
    protected void onRestoreInstanceState(Parcelable state) {
        if (!(state instanceof CoordinatorMenu.SavedState)) {
            super.onRestoreInstanceState(state);
            return;
        }
    
        final CoordinatorMenu.SavedState ss = (CoordinatorMenu.SavedState) state;
        super.onRestoreInstanceState(ss.getSuperState());
    
        if (ss.menuState == MENU_OPENED) {//读取到的状态是打开的话
            openMenu();//打开菜单
        }
    }
    

    2.避免过度绘制menumain在滑动过程中会有重叠部分,重叠部分也就是menu被遮盖的部分,是不需要再绘制的,我们只需要绘制显示出来的menu部分,如图所示:


    drawChild方法中增加以下代码
     @Override
    protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
        final int restoreCount = canvas.save();//保存画布当前的剪裁信息
        
        final int height = getHeight();
        final int clipLeft = 0;
        int clipRight = mMainView.getLeft();
        if (child == mMenuView) {
            canvas.clipRect(clipLeft, 0, clipRight, height);//剪裁显示的区域
        }
        
        boolean result = super.drawChild(canvas, child, drawingTime);//绘制当前view
    
        //恢复画布之前保存的剪裁信息
        //以正常绘制之后的view
        canvas.restoreToCount(restoreCount);
    }
    

    写在最后

    至此,我们的侧滑菜单即实现了功能,又优化并处理了些细节。如果有时候遇到功能不知道怎么实现,其实最好的解决方向就是先看看官方有没有实现过这样的功能,再去他们的源码里寻找答案,比如说我这里实现的阴影绘制以及过度绘制优化都是参照于官方控件DrawerLayout,阅读官方源码不仅能让你实现功能,还能激发你并改善你的代码质量,会有一种卧槽,代码原来这么写最好了的感叹。

    本文源码地址:https://github.com/bestTao/CoordinatorMenu有问题欢迎提issue

    你也可以直接在项目中引入这个控件:

    1. 先添加以下代码到你项目中的根目录的build.gradle
    allprojects {
            repositories {
                ...
                maven { url 'https://jitpack.io' }
            }
    }
    
    1. 再引入依赖即可:
    dependencies {
                compile 'com.github.bestTao:CoordinatorMenu:v1.0.2'
    }
    

    详细内容及最新版本可以参考[README.md]

    相关文章

      网友评论

      • Android之路:nice.看过很多ViewDragHelper的文章,本篇文章算是数一数二的!:smile:
        cv大法师: @Android之路 谢谢你的赞赏😁
      • 巴黎没有摩天轮Li:用DrawerLayout多好,用不了300行
        cv大法师:@巴黎没有摩天轮Li DrawerLayout和QQ6.X的侧滑菜单的效果不一样的
        巴黎没有摩天轮Li:@cv大法师 直接使用不行吗?
        cv大法师:@巴黎没有摩天轮Li DrawerLayout不是现成的控件吗,你的意思是改他的源码?
      • 1446be8a39a0:关于过度重复绘制,这是裁剪了menu,main也有一部分看不到,也需要裁剪,请问我的理解对吗??
        cv大法师:@cmm451739644 menu需要剪裁是因为它和main有一部分会重叠在一起,这个重叠的部分虽然被main遮挡了,但还是存在与屏幕内的,所以会被绘制。
        1446be8a39a0:@cv大法师 这样的话 那menu会被绘制???
        cv大法师:你说的main看不到的部分是指超出屏幕外的那部分吧。刚开始我也是以为超出屏幕的部分也需要剪裁,但是根据以往的经验超出屏幕的这部分是不会被绘制的,所以无需剪裁。
      • b408b9daab58:使用ViewDragHelper一切就简单多了
        1446be8a39a0:关于过度重复绘制,这是裁剪了menu,main也有一部分看不到,也需要裁剪,请问我的理解对吗??
        cv大法师:@画船听雨眠king 的确!至少滑动处理这块就变得很轻松
      • 风舞尘起:qq的有不一样的地方,就是滑出来的菜单是固定的,是右边逐渐往右边滑动的
        cv大法师:菜单一直在底层,菜单的是自身右滑加上主界面右滑才逐渐展开的

      本文标题:自定义控件?试试300行代码实现QQ侧滑菜单

      本文链接:https://www.haomeiwen.com/subject/flzjbttx.html