手写酷狗侧滑菜单效果
上一节分析了事件传递的源码 Android事件分发机制源码详解-最新 API,那么趁热打铁来个应用的小示例。我这里尝试写一个酷狗的侧滑菜单。
本文的大体内容如下:
- 分析
- 具体实现
- 处理滑动缩放效果
- 滑动临界值的处理
- 处理快速滑动
- 智能处理开关
先看一下酷狗自己的效果,然后对比下我们实现的效果,已经很接近了。
效果动图,可以查看下方录制的视频
酷狗自己的侧滑效果视频地址
我们自己写的侧滑效果视频地址
界面是不卡顿的,我为了演示退出按钮在拖动的过程中的变化,故意滑动的慢了些。
分析
先分析下酷狗菜单的滑动体验:
- 打开软件,默认菜单是关闭的
- 在内容的空白区域或屏幕左侧向右滑动,菜单打开
- 点击主页导航三道杠,菜单快速打开
- 菜单打开的过程中,有几个效果
- 右侧内容区域一个缩放动画
- 左侧菜单一个缩放动画和一个透明度渐变动画
- 菜单关闭状态:往右滑动距离小于 halfMenuWidth,松手关闭菜单;如果距离大于 halfMenuWidth,则松手打开菜单
- 菜单打开状态:往左滑动距离小于 halfMenuWidth,松手打开菜单;如果距离大于 halfMenuWidth,则松手关闭菜单
- 快速左右滑动屏幕,关闭或者打开菜单
- 菜单处于打开状态,点击菜单区域,打开菜单的内容
- 菜单处于打开状态,点击右侧内容区域,关闭菜单,内容区域点击无响应
具体实现
因为可以左右来回滑动,我这里选择继承自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 源码和图片资源哦~
微信扫码快速直达