前言
前面使用ViewDragHelper实现过滑动解锁、浮动按钮,可以说ViewDragHelper真是一个神器,实现拽托、移动,都只需要很少的代码就可以实现,手势相关代码都在ViewDragHelper内部帮我们封装好了,本篇再次使用ViewDragHelper来实现QQ的侧滑菜单。
说到这个侧边栏,一开始学习安卓的时候,看的是一脸懵逼,一直都想实现它,但因当时水平不过,不足以有能力去实现,如今做安卓也有些年头了,终于也用ViewDragHelper实现了这个功能。想想还是有些兴奋呢~
当然本篇的代码,只能实现基本的左滑菜单,并且可能会有一些手势冲突没有处理,但是主线依旧是左侧滑,其他处理都是锦上添花~如果实际开发中要用,最好还是使用已经造好的轮子。(轮子虽好,但始终不是自己的,有空自己动手,才能理解到精髓喔)
成品展示
ViewDragHelper实战,实现QQ侧滑菜单.gif分析
布局分析
侧滑菜单,一共分为3个部分:
-
页面整体,包含菜单部分和内容部分
-
菜单部分,和内容布局同一个层级,被SlidingMenu控件包裹。
-
内容部分,同上,和菜单部分同一个层级,被SlidingMenu控件包裹。不难发现我们的效果中,打开菜单时,内容区域会有一个黑色遮罩,根据滑动的距离从透明到半透明,所以内容部分还有一个遮罩。
xml布局
- 总布局
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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"
tools:context=".MainActivity">
<com.zh.android.slidingmenu.sample.SlidingMenu
android:id="@+id/sliding_menu"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/layout_menu" />
<include layout="@layout/layout_content" />
</com.zh.android.slidingmenu.sample.SlidingMenu>
</FrameLayout>
- 菜单布局
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/menu_list"
android:layout_width="280dp"
android:layout_height="match_parent" />
- 内容布局
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/tool_bar"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:background="@color/colorPrimary"
app:title="@string/app_name"
app:titleTextColor="@android:color/white" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/content_list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
<View
android:id="@+id/content_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0"
android:background="#8C000000"
tools:alpha="1" />
</FrameLayout>
行为分析
-
打开菜单
- 菜单从左向右拉出,菜单跟随内容一起向右移动。
- 内容从左侧拉到完全出现,不能继续拉,内容也不能继续拉。
-
关闭菜单
- 内容从右向左拉回,菜单跟随内容一起向左移动。
- 内容从右向左移动,完全移出屏幕时,内容不能继续向左拉。
-
其他细节
- 拽托菜单和内容都可以拉动整体菜单的移动。
- 遮罩根据拽托的位置,形成一个比值,让遮罩的透明度改变。
- 菜单打开时,比值为1,菜单关闭时,比值为0。
- 从关闭到打开,比值从0开始,逐渐趋向1。
逐步实现
-
创建侧滑菜单布局 SlidingMenu
- 复写onFinishInflate(),获取子控件和布局子控件,必须只有菜单和内容这2个子控件。
- 复写onLayout(),布局菜单和内容。
- 菜单布局,left值为负的菜单宽度,right值为父布局的left,所以一开始菜单都默认隐藏在屏幕的左侧,是看不到的。
- 内容区域,所有值都和父布局一样即可,就是铺满父布局。
public class SlidingMenu extends FrameLayout {
/**
* 菜单View
*/
private View vMenuView;
/**
* 内容View
*/
private View vContentView;
public SlidingMenu(@NonNull Context context) {
this(context, null);
}
public SlidingMenu(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public SlidingMenu(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs, defStyleAttr);
}
private void init(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
//初始化...
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
//获取所有子View,确保只有菜单和内容这2个控件
int childCount = getChildCount();
if (childCount != 2) {
throw new IllegalStateException("侧滑菜单内只能有2个子View,分别是菜单和内容");
}
vMenuView = getChildAt(0);
vContentView = getChildAt(1);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
//布局菜单和内容,菜单在最左边,普通状态是看不到的
vMenuView.layout(-vMenuView.getMeasuredWidth(), top, left, bottom);
//内容View铺满整个父控件
vContentView.layout(left, top, right, bottom);
}
}
-
新建菜单状态回调接口,提供以下回调方法:
- onMenuOpen(),当菜单开启时回调。
- onSliding(float fraction),正在滑动时回调,回传fraction值,为滑动比值。
- onMenuClose(),当菜单关闭时回调。
/**
* 菜单状态改变监听
*/
private OnMenuStateChangeListener mMenuStateChangeListener;
/**
* 菜单状态改变监听
*/
public interface OnMenuStateChangeListener {
/**
* 当菜单开启时回调
*/
void onMenuOpen();
/**
* 正在滑动时回调
*
* @param fraction 滑动百分比值
*/
void onSliding(float fraction);
/**
* 当菜单关闭时回调
*/
void onMenuClose();
}
/**
* 设置状态改变监听
*
* @param menuStateChangeListener 监听器
*/
public void setOnMenuStateChangeListener(OnMenuStateChangeListener menuStateChangeListener) {
mMenuStateChangeListener = menuStateChangeListener;
}
- 创建ViewDragHelper,将事件委托给ViewDragHelper处理
- 复写onInterceptTouchEvent(),事件委托给ViewDragHelper。
- 复写onTouchEvent(),事件委托给ViewDragHelper。
- 复写computeScroll(),因为ViewDragHelper的滚动是依靠Scroller的,所以需要将Scroller的相关处理交给ViewDragHelper。
/**
* 拽托帮助类
*/
private ViewDragHelper mViewDragHelper;
/**
* 初始化
*/
private void init(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
mViewDragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() {
//...先省略,下面会说
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
//将onInterceptTouchEvent委托给ViewDragHelper
return mViewDragHelper.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
//将onTouchEvent委托给ViewDragHelper
mViewDragHelper.processTouchEvent(event);
return true;
}
@Override
public void computeScroll() {
super.computeScroll();
//判断是否移动到头了,未到头则继续
if (mViewDragHelper != null) {
if (mViewDragHelper.continueSettling(true)) {
invalidate();
}
}
}
-
复写ViewDragHelper,处理横向侧滑
- 复写tryCaptureView(),确定菜单和内容可以拽托
- 复写getViewHorizontalDragRange(),返回拽托范围,返回非0值即可,某些情况需要该值来确定是否可以拽托。
- 复写clampViewPositionHorizontal(),处理横向拽托,由于菜单和内容都可以拽托,拽托这2部分都会回调,但方法传入的child为菜单或内容。所以需要判断拽托的控件来处理。
-
拽托的是菜单,处理如下:
- 左边界left不能超过菜单宽度,因坐标系,屏幕左侧为负值,所以不能小于负的菜单宽度。
- 左边界left不能超过0,因为只能完全显示菜单后,就不能继续向右拽托了。
- 其他情况为允许值,直接返回传入的left值即可。
-
拽托的是内容,处理如下:
- 左边界left不能超过0,因为内容不能拽托出屏幕左侧。
- 左边界left不能超过菜单宽度,因为菜单完全显示后,内容就不能继续向右拽托了。
- 其他情况为允许值,直接返回传入的left值即可。
-
//...省略其他代码
/**
* 初始化
*/
private void init(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
mViewDragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(@NonNull View child, int pointerId) {
//菜单和内容都可以拽托
return child == vMenuView || child == vContentView;
}
@Override
public int getViewHorizontalDragRange(@NonNull View child) {
//拽托范围,返回非0值即可,某些情况需要该值来确定是否可以拽托
return vContentView.getWidth();
}
@Override
public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {
//处理横向拽托,获取菜单宽度
int menuWidth = vMenuView.getWidth();
//因为拽托菜单和内容传入的对象不同,我们需要处理2种情况
if (child == vMenuView) {
//拽托的是菜单
if (left < -menuWidth) {
//左边距离,最多只能完全隐藏于屏幕最左侧
return -menuWidth;
} else if (left > 0) {
//左边距离,最多能完全出现在屏幕
return 0;
} else {
return left;
}
} else if (child == vContentView) {
//拽托的是内容区域,不能移动超出最左边的屏幕
if (left < 0) {
return 0;
} else if (left > menuWidth) {
//最多不能超过菜单的宽度
return menuWidth;
} else {
return left;
}
}
return 0;
}
});
}
//...省略其他代码
- 处理拽托时,联动处理
如果只复写clampViewPositionHorizontal(),只能拽托菜单或者内容的单独移动,它们并不是联动的,我们需要拽托菜单时,内容随着一起移动。相应的,拽托内容时,菜单也会随着一起移动。
- 复写onViewPositionChanged(),当拽托菜单或内容时,回调移动位置等相关信息。
- 同样需要判断child对象是菜单还是内容()。
- 拽托对象为菜单时,手动调用内容View的layout()方法,让内容View移动。
- 拽托对象为内容时,手动调用菜单View的layout()方法,让内容View移动。
- 处理开、关状态处理以及回调监听。
- 定义菜单打开、关闭方法
- openMenu(),打开菜单。
- closeMenu(),关闭菜单。
//...省略其他代码
/**
* 初始化
*/
private void init(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
mViewDragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() {
@Override
public void onViewPositionChanged(@NonNull View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
//处理联动,拽托菜单布局,让内容布局跟着动
if (changedView == vMenuView) {
int newLeft = vContentView.getLeft() + dx;
int right = newLeft + vContentView.getWidth();
vContentView.layout(newLeft, top, right, getBottom());
} else if (changedView == vContentView) {
//拽托内容布局,让菜单布局跟着动
int newLeft = vMenuView.getLeft() + dx;
vMenuView.layout(newLeft, top, left, getBottom());
}
//处理滑动中的回调,计算滑动比值
if (mMenuStateChangeListener != null) {
float fraction = (vContentView.getLeft() * 1f) / vMenuView.getWidth();
mMenuStateChangeListener.onSliding(fraction);
}
//处理开、关状态,由于该方法会不断被回调,所以需要加上状态值,保证只回调一次给监听器
if ((vMenuView.getLeft() == -vMenuView.getWidth()) && isOpenMenu) {
//关
isOpenMenu = false;
if (mMenuStateChangeListener != null) {
mMenuStateChangeListener.onMenuClose();
}
} else if (vMenuView.getLeft() == 0 && !isOpenMenu) {
//开
isOpenMenu = true;
if (mMenuStateChangeListener != null) {
mMenuStateChangeListener.onMenuOpen();
}
}
}
});
/**
* 打开菜单
*/
public void openMenu() {
mViewDragHelper.smoothSlideViewTo(vMenuView, 0, vMenuView.getTop());
ViewCompat.postInvalidateOnAnimation(SlidingMenu.this);
}
/**
* 关闭菜单
*/
public void closeMenu() {
mViewDragHelper.smoothSlideViewTo(vMenuView, -vMenuView.getWidth(), vMenuView.getTop());
ViewCompat.postInvalidateOnAnimation(SlidingMenu.this);
}
}
//...省略其他代码
-
处理松手回弹和fling操作
- 首先,先处理fling操作,当向左快速惯性滑动时,xvel值小于0,关闭菜单。如果是向右,则xvel值大于300则打开菜单。
- 如果不是fling操作,则处理为松手回台,判断菜单打开时的left时,如果小于菜单宽度的一半,则为关闭,否则为打开。
//...省略其他代码
/**
* 初始化
*/
private void init(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
mViewDragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() {
@Override
public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
//fling操作
if (xvel < 0) {
//向左
closeMenu();
return;
} else if (xvel > 300) {
//向右
openMenu();
return;
}
//松手回弹
float halfMenuWidth = vMenuView.getWidth() / 2f;
//如果菜单打开的范围小于菜单的一半,则当为关
if (vMenuView.getLeft() < -halfMenuWidth) {
//关
closeMenu();
} else {
//开
openMenu();
}
}
});
}
//...省略其他代码
- 遮罩
上面说到内容区域有一个遮罩,当菜单从关闭到打开的过程中,是从亮到黑,其实就是一个半透明的遮罩,它的透明度从0到1的过程。
我们可以以内容区域的左边距(就是left值)为起点,和菜单View的宽度做一个比值,将比值交给回调,外部收到回调时,再进行遮罩透明度设置。
@Override
public void onViewPositionChanged(@NonNull View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
...
//处理滑动中的回调,计算滑动比值
if (mMenuStateChangeListener != null) {
float fraction = (vContentView.getLeft() * 1f) / vMenuView.getWidth();
mMenuStateChangeListener.onSliding(fraction);
}
...
}
设置回调,处理遮罩透明度
public class MainActivity extends AppCompatActivity {
/**
* 侧滑菜单
*/
private SlidingMenu vSlidingMenu;
/**
* 透明度估值器
*/
private FloatEvaluator mAlphaEvaluator;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
findView();
bindView();
}
private void findView() {
...省略其他控件
vSlidingMenu = findViewById(R.id.sliding_menu);
vContentBg = findViewById(R.id.content_bg);
...省略其他控件
}
private void bindView() {
//创建估值器
mAlphaEvaluator = new FloatEvaluator();
//------------ 重点:设置侧滑菜单的状态切换监听 ------------
vSlidingMenu.setOnMenuStateChangeListener(new SlidingMenu.OnMenuStateChangeListener() {
@Override
public void onMenuOpen() {
Log.d(TAG, "菜单打开");
//让黑色遮罩,禁用触摸
vContentBg.setClickable(true);
}
@Override
public void onSliding(float fraction) {
Log.d(TAG, "菜单拽托中,百分比:" + fraction);
//设定最小、最大透明度值
float startValue = 0;
float endValue = 0.55f;
//估值当前的透明度值,并设置
Float value = mAlphaEvaluator.evaluate(fraction, startValue, endValue);
vContentBg.setAlpha(value);
}
@Override
public void onMenuClose() {
Log.d(TAG, "菜单关闭");
//让黑色遮罩,恢复触摸
vContentBg.setClickable(false);
}
});
//------------ 重点:设置侧滑菜单的状态切换监听 ------------
}
}
完整代码
public class SlidingMenu extends FrameLayout {
/**
* 菜单View
*/
private View vMenuView;
/**
* 内容View
*/
private View vContentView;
/**
* 拽托帮助类
*/
private ViewDragHelper mViewDragHelper;
/**
* 菜单状态改变监听
*/
private OnMenuStateChangeListener mMenuStateChangeListener;
/**
* 菜单是否开启
*/
private boolean isOpenMenu;
public SlidingMenu(@NonNull Context context) {
this(context, null);
}
public SlidingMenu(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public SlidingMenu(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs, defStyleAttr);
}
/**
* 初始化
*/
private void init(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
mViewDragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(@NonNull View child, int pointerId) {
//菜单和内容都可以拽托
return child == vMenuView || child == vContentView;
}
@Override
public int getViewHorizontalDragRange(@NonNull View child) {
//拽托范围,返回非0值即可,某些情况需要该值来确定是否可以拽托
return vContentView.getWidth();
}
@Override
public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {
//处理横向拽托,获取菜单宽度
int menuWidth = vMenuView.getWidth();
//因为拽托菜单和内容传入的对象不同,我们需要处理2种情况
if (child == vMenuView) {
//拽托的是菜单
if (left < -menuWidth) {
//左边距离,最多只能完全隐藏于屏幕最左侧
return -menuWidth;
} else if (left > 0) {
//左边距离,最多能完全出现在屏幕
return 0;
} else {
return left;
}
} else if (child == vContentView) {
//拽托的是内容区域,不能移动超出最左边的屏幕
if (left < 0) {
return 0;
} else if (left > menuWidth) {
//最多不能超过菜单的宽度
return menuWidth;
} else {
return left;
}
}
return 0;
}
@Override
public void onViewPositionChanged(@NonNull View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
//处理联动,拽托菜单布局,让内容布局跟着动
if (changedView == vMenuView) {
int newLeft = vContentView.getLeft() + dx;
int right = newLeft + vContentView.getWidth();
vContentView.layout(newLeft, top, right, getBottom());
} else if (changedView == vContentView) {
//拽托内容布局,让菜单布局跟着动
int newLeft = vMenuView.getLeft() + dx;
vMenuView.layout(newLeft, top, left, getBottom());
}
//处理滑动中的回调,计算滑动比值
if (mMenuStateChangeListener != null) {
float fraction = (vContentView.getLeft() * 1f) / vMenuView.getWidth();
mMenuStateChangeListener.onSliding(fraction);
}
//处理开、关状态,由于该方法会不断被回调,所以需要加上状态值,保证只回调一次给监听器
if ((vMenuView.getLeft() == -vMenuView.getWidth()) && isOpenMenu) {
//关
isOpenMenu = false;
if (mMenuStateChangeListener != null) {
mMenuStateChangeListener.onMenuClose();
}
} else if (vMenuView.getLeft() == 0 && !isOpenMenu) {
//开
isOpenMenu = true;
if (mMenuStateChangeListener != null) {
mMenuStateChangeListener.onMenuOpen();
}
}
}
@Override
public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
//fling操作
if (xvel < 0) {
//向左
closeMenu();
return;
} else if (xvel > 300) {
//向右
openMenu();
return;
}
//松手回弹
float halfMenuWidth = vMenuView.getWidth() / 2f;
//如果菜单打开的范围小于菜单的一半,则当为关
if (vMenuView.getLeft() < -halfMenuWidth) {
//关
closeMenu();
} else {
//开
openMenu();
}
}
});
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
//获取所有子View,确保只有菜单和内容这2个控件
int childCount = getChildCount();
if (childCount != 2) {
throw new IllegalStateException("侧滑菜单内只能有2个子View,分别是菜单和内容");
}
vMenuView = getChildAt(0);
vContentView = getChildAt(1);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
//布局菜单和内容,菜单在最左边,普通状态是看不到的
vMenuView.layout(-vMenuView.getMeasuredWidth(), top, left, bottom);
//内容View铺满整个父控件
vContentView.layout(left, top, right, bottom);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
//将onInterceptTouchEvent委托给ViewDragHelper
return mViewDragHelper.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
//将onTouchEvent委托给ViewDragHelper
mViewDragHelper.processTouchEvent(event);
return true;
}
@Override
public void computeScroll() {
super.computeScroll();
//判断是否移动到头了,未到头则继续
if (mViewDragHelper != null) {
if (mViewDragHelper.continueSettling(true)) {
invalidate();
}
}
}
/**
* 打开菜单
*/
public void openMenu() {
mViewDragHelper.smoothSlideViewTo(vMenuView, 0, vMenuView.getTop());
ViewCompat.postInvalidateOnAnimation(SlidingMenu.this);
}
/**
* 关闭菜单
*/
public void closeMenu() {
mViewDragHelper.smoothSlideViewTo(vMenuView, -vMenuView.getWidth(), vMenuView.getTop());
ViewCompat.postInvalidateOnAnimation(SlidingMenu.this);
}
/**
* 菜单状态改变监听
*/
public interface OnMenuStateChangeListener {
/**
* 当菜单开启时回调
*/
void onMenuOpen();
/**
* 正在滑动时回调
*
* @param fraction 滑动比值
*/
void onSliding(float fraction);
/**
* 当菜单关闭时回调
*/
void onMenuClose();
}
/**
* 设置状态改变监听
*
* @param menuStateChangeListener 监听器
*/
public void setOnMenuStateChangeListener(OnMenuStateChangeListener menuStateChangeListener) {
mMenuStateChangeListener = menuStateChangeListener;
}
}
总结
项目代码,我提交到了github上,有需要的同学可以自行clone:Github地址
使用ViewDragHelper这个神器,无论是做拽托、移动都很方便,本篇的难点其实在于坐标计算,尤其在onViewPositionChanged()方法中拽托联动菜单和内容这2个部分,计算2个控件的4个点会比较费脑子外,其他计算倒还好。
网友评论