Android 自定义控件并没有什么捷径可走,需要不断得模仿练习才能出师。这其中进行模仿练习的 demo 的选择是至关重要的,最优选择莫过于官方的控件了,但是官方控件动辄就是几千行代码往往可能容易让人望而却步。本文介绍如何理解并实现 Android 端的 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 了,接下来就是限制它水平滑动的范围了,范围如下图所示:

改写上面的水平滑动方法,

@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 不能拖动它本身,也不能拖动 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 ,fuxk 。所以这样计算的话精度严重丢失,不能达到同步移动的效果。
所以我们只能换一种思维,使用它们之间的另一种关系: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/qiantao94/CoordinatorMenu有问题欢迎提issue

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

  1. 先添加以下代码到你项目中的根目录的build.gradle
    allprojects {
        repositories {
            ...
            maven { url 'https://jitpack.io' }
        }
    }
  2. 再引入依赖即可:
    dependencies {
        compile 'com.github.bestTao:CoordinatorMenu:v1.0.2'
    }
    详细内容及最新版本可以参考 [README.md]