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的位置

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

@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,呵呵。所以这样计算的话精度严重丢失,不能达到同步移动的效果。
所以我们只能换一种思维,使用它们之间的另一种关系: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' }
         }
    }
  2. 再引入依赖即可:
    dependencies {
             compile 'com.github.bestTao:CoordinatorMenu:v1.0.2'
    }
    详细内容及最新版本可以参考[README.md]