Android开发Android技术知识Android开发

手写酷狗侧滑菜单效果

2019-01-22  本文已影响108人  拂晓是个小人物

上一节分析了事件传递的源码 Android事件分发机制源码详解-最新 API,那么趁热打铁来个应用的小示例。我这里尝试写一个酷狗的侧滑菜单。

本文的大体内容如下:

  1. 分析
  2. 具体实现
  3. 处理滑动缩放效果
  4. 滑动临界值的处理
  5. 处理快速滑动
  6. 智能处理开关

先看一下酷狗自己的效果,然后对比下我们实现的效果,已经很接近了。


效果动图,可以查看下方录制的视频

酷狗自己的侧滑效果视频地址
我们自己写的侧滑效果视频地址
界面是不卡顿的,我为了演示退出按钮在拖动的过程中的变化,故意滑动的慢了些。

分析

先分析下酷狗菜单的滑动体验:

  1. 打开软件,默认菜单是关闭的
  2. 在内容的空白区域或屏幕左侧向右滑动,菜单打开
  3. 点击主页导航三道杠,菜单快速打开
  4. 菜单打开的过程中,有几个效果
    • 右侧内容区域一个缩放动画
    • 左侧菜单一个缩放动画和一个透明度渐变动画
  5. 菜单关闭状态:往右滑动距离小于 halfMenuWidth,松手关闭菜单;如果距离大于 halfMenuWidth,则松手打开菜单
  6. 菜单打开状态:往左滑动距离小于 halfMenuWidth,松手打开菜单;如果距离大于 halfMenuWidth,则松手关闭菜单
  7. 快速左右滑动屏幕,关闭或者打开菜单
  8. 菜单处于打开状态,点击菜单区域,打开菜单的内容
  9. 菜单处于打开状态,点击右侧内容区域,关闭菜单,内容区域点击无响应

具体实现

因为可以左右来回滑动,我这里选择继承自HorizontalScrollView通过自定义扩展来实现,起名为「DrawerMenu」抽屉菜单。

继承自 HorizontalScrollView,实现一下构造,这里我用到了一个自定义属性dmRightMargin,表示菜单打开时的右边距,不同项目值可能都不太一样。一个菜单布局和一个内容布局很简单不贴了。

<?xml version="1.0" encoding="utf-8"?>
<com.lm.kugoumenu.DrawerMenu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/skin_menu_bg"
    android:fadingEdge="none"
    android:overScrollMode="never"
    app:dmRightMargin="45dp"
    tools:context=".KuGouActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal">
        <!-- 菜单-->
        <include layout="@layout/kugou_menu" />

        <!-- 内容 -->
        <include layout="@layout/kugou_content" />
    </LinearLayout>

</com.lm.kugoumenu.DrawerMenu>

运行到手机上可以先看下效果,果不其然的布局显示乱了,因为我们什么都没做呢。

我们指定下菜单和内容的大小就可以了。

// 解析完,拿到菜单和内容,指定大小
@Override
protected void onFinishInflate() {
    super.onFinishInflate();

    ViewGroup container = (ViewGroup) getChildAt(0);
    int childCount = container.getChildCount();
    if (childCount != 2) {
        throw new RuntimeException("LinearLayout must be contains two childviews.");
    }

    mMenuView = container.getChildAt(0);
    LinearLayout.LayoutParams menuParams = (LinearLayout.LayoutParams) mMenuView.getLayoutParams();
    menuParams.width = getScreenWidth() - mDrawerRightMargin;
    mMenuView.setLayoutParams(menuParams);

    mContentView = container.getChildAt(1);
    LinearLayout.LayoutParams contentParams = (LinearLayout.LayoutParams) mContentView.getLayoutParams();
    contentParams.width = getScreenWidth();
    mContentView.setLayoutParams(contentParams);
}

指定完我们再看下效果,已经可以滑动了,只不过没什么效果而已,同时打开应用默认菜单打开的,可以通过 scrollTo() 滚动一个菜单的宽度即可。

scrollTo(mMenuWidth,0); 加到 onFinishInflate() 方法里边再次尝试发现没什么用,这是因为我们的调用时机不对,onFinishInflate()方法是在我们的 XML 布局文件解析完后调用的,这个时候布局虽然解析完了,但是并没有摆放呢,所以把滑动的代码放到 onLayout 即可。

处理滑动缩放效果

在滑动的过程中需要处理菜单和内容的动画效果,我们监听滑动过程,执行缩放和透明度动画;

View 里边有一个 onScrollChanged() 方法, 只要 View 的内容有滑动,就会回调到这个方法中来。这样我们就不用自己处理滑动的监听了


float scaleRatio = 1.0f * l / mMenuWidth; // 1.0 -> 0
float contentScale = 0.7f + 0.3f * scaleRatio; // 1.0 -> 0
// ViewCompat 已经过时了
// ViewCompat.setScaleX(mContentView, contentScale);

// 内容 缩放动画
mContentView.setScaleX(contentScale);
mContentView.setScaleY(contentScale);

// 菜单 透明度动画和缩放动画
float menuScale = 0.7f + 0.3f * (1 - scaleRatio); // 0.7 -> 1
float menuAlpha = 0.6f + 0.4f * (1 - scaleRatio); // 0.6 -> 1
mMenuView.setScaleX(menuScale);
mMenuView.setScaleY(menuScale);
mMenuView.setAlpha(menuAlpha);

运行起来,发现可以正常打开了,但是我们的内容页不见了,这是因为内容页缩放后不在我们视野里边了,下图展示了菜单的几种状态(灰色框代表手机屏幕,粉色框为菜单,深色框为内容页),我们想要的就是中间这幅图展示的,菜单完全打开内容部分缩小看的见;然而现在的状态其实是右图,是由于默认的缩放中心点是 View 的中心位置,缩放后其实已经跑到我们的屏幕外边去了,我们修改 contentView 左侧边的中点位置为缩放的中心位置即可。

image

滑动临界值的处理

当我们滑动手机屏幕,距离超过一定值时松手,我们来处理下使它能够友好的关闭或者打开,这就用到了上一篇讲到的事件分发的知识了

@Override
public boolean onTouchEvent(MotionEvent ev) {
    // 注意:默认界面一打开,我们实际上是往右滑动了一个 Menu 的宽度的;
    if (ev.getAction() == MotionEvent.ACTION_UP) {
        int scrollX = getScrollX();
        if (scrollX > mMenuWidth / 2) {
            closeMenu();
        } else {
            openMenu();
        }
        return true; // ViewGroup处理,消费掉事件
    }
    return super.onTouchEvent(ev);
}

处理快速滑动

左右快速滑动页面可以打开和关闭菜单的处理,思路是这样的:我们监测到屏幕快速滑动,配合滑动的方向来决定菜单的打开与否。

这里我借助于一个手势解析处理类 「GestureDetector」,来帮助我们监测快速滑动。GestureDetector 用起来很简单,直接 new 就好了,需要一个上下文和手势的监听回调 GestureDetector(Context, OnGestureListener)

// GestureDetector.SimpleOnGestureListener 的一个回调方法,惯性滑动(飞滑)时,回调到这里,前提是想要响应,需要在 ViewGroup.onTouchEvent(MotionEvent)中让 GestureDetector 来处理事件

// 右滑  x 为正,左滑为负,上滑  y 为负,下滑为正
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
    if (mMenuOpen) {
        if (velocityX < 0 && Math.abs(velocityX) > Math.abs(velocityY)) { // 关闭
            closeMenu();
            return true;
        }
    } else {
        if (velocityX > 0 && Math.abs(velocityX) > Math.abs(velocityY)) { // 打开
            openMenu();
            return true;
        }
    }
    return super.onFling(e1, e2, velocityX, velocityY);
}

智能处理开关

效果再描述一下:
现象一:菜单处于打开状态,点击菜单区域,打开菜单的内容
现象二:菜单处于打开状态,点击右侧内容区域,关闭菜单,内容区域点击无响应

这个就需要我们根据不同的点击位置,来处理事件拦截与否;菜单打开时点击内容区域,就把事件拦截掉,点击其它位置交给系统处理。

这里给出两种方案可以参考:
方案一: 根据位置拦截事件
方案二:点击对应区域,不拦截不消费,但是写自己的消费事件逻辑

// 方案一
// 如果使用这种方式处理的话,放开 onTouchEvent() 中的 「拦截代码块」
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    // 菜单打开,点击菜单右侧部分 要关闭菜单; 点击菜单区域,则菜单列表响应点击
    mTouchContentCloseMenu = false;
    if (mMenuOpen && ev.getRawX() > mMenuWidth) {
        mTouchContentCloseMenu = true;
        Log.e(TAG, "onInterceptTouchEvent: <<<<<<<<<<<<" );
        // 一旦拦截,事件就直接走到自己的 onTouchEvent() 方法里了
        return true;
    }

    return super.onInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent ev) {
    // 注意点:默认界面一打开,我们实际上是往右滑动了一个 Menu 的宽度的;

    // 拦截代码块:配合拦截事件代码一块使用 (如果在事件分发中写逻辑,这个地方可以屏蔽掉)
    if (mTouchContentCloseMenu && ev.getAction() == MotionEvent.ACTION_UP) {
        Log.e(TAG, "onTouchEvent: --> closeMenu()");
        mTouchContentCloseMenu = false;
        closeMenu();
        return true;
    }

    if (mGestureDetector.onTouchEvent(ev)) {
        return true;
    }

    if (ev.getAction() == MotionEvent.ACTION_UP) {
        int scrollX = getScrollX();
        //Log.e(TAG, "onTouchEvent: >>> scrollX=" + scrollX + " mMenuWidth / 2=" + (mMenuWidth / 2) + " x=" + ev.getX() + " rawx=" + ev.getRawX());
        if (scrollX > mMenuWidth / 2) {
            closeMenu();
        } else {
            openMenu();
        }
        return true;
    }
    return super.onTouchEvent(ev);
}


// 方案二
// 如果使用这种方式处理的话,屏蔽掉 onTouchEvent() 中的「拦截代码块」
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    if (mMenuOpen && ev.getAction() == MotionEvent.ACTION_DOWN) {
        if (ev.getRawX() > mMenuWidth) {

            closeMenu();

            // 返回 false 不分发 不处理事件,连 ViewGroup 自己的 onTouchEvent() 方法都不会走;
            // 事件会直接交给顶级父 View,最终事件会传递到 Activity 的 onTouchEvent()

            // 返回 true,表示事件分发成功(事实上并没有分发给子 View),并且被消费(事实需要我们自己去处理消费事件的逻辑,即 closeMenu() )
            // 后续事件(MOVE UP)会直接走到自己的 onTouchEvent()方法中
            // 如果我们在这里自己写了事件消费的逻辑(调用了closeMenu()),意味着没有后续事件的,因为菜单已关闭,可以屏蔽掉 closeMenu(),自己打日志体会吧
            return false;
        }
    }
    return super.dispatchTouchEvent(ev);
}

最后,还有一个小瑕疵,就是在滑动打开菜单的过程中,「退出」按钮在酷狗上的做法是,一开始的位置在内容页的下面,伴随着继续往右滑动,会完全显示出来,是一个很好的细节体验,这里就不再贴代码了,留个小悬念自己思考下「源码里边有处理」。

好了,到目前为止,我们自己写的仿酷狗侧滑菜单已经实现的七七八八了,不知道最后这个「智能处理」有没有讲清楚,嗯,反正我是明白了,如果对事件分发还不太熟悉的,可以翻看上一篇Android事件分发机制源码详解-最新 API ,如果有疑问的话,写一遍运行起来打个日志瞅一瞅。

可以用微信扫一扫获取 demo 源码和图片资源哦~

微信扫码快速直达
上一篇下一篇

猜你喜欢

热点阅读