ViewDragHelper实战,实现QQ侧滑菜单

365皇冠体育网址 2026-01-26 09:00:16 admin 9831 375
ViewDragHelper实战,实现QQ侧滑菜单

前言

前面使用ViewDragHelper实现过滑动解锁、浮动按钮,可以说ViewDragHelper真是一个神器,实现拽托、移动,都只需要很少的代码就可以实现,手势相关代码都在ViewDragHelper内部帮我们封装好了,本篇再次使用ViewDragHelper来实现QQ的侧滑菜单。

说到这个侧边栏,一开始学习安卓的时候,看的是一脸懵逼,一直都想实现它,但因当时水平不过,不足以有能力去实现,如今做安卓也有些年头了,终于也用ViewDragHelper实现了这个功能。想想还是有些兴奋呢~

当然本篇的代码,只能实现基本的左滑菜单,并且可能会有一些手势冲突没有处理,但是主线依旧是左侧滑,其他处理都是锦上添花~如果实际开发中要用,最好还是使用已经造好的轮子。(轮子虽好,但始终不是自己的,有空自己动手,才能理解到精髓喔)

成品展示

ViewDragHelper实战,实现QQ侧滑菜单.gif

分析

布局分析

侧滑菜单,一共分为3个部分:

页面整体,包含菜单部分和内容部分

菜单部分,和内容布局同一个层级,被SlidingMenu控件包裹。

内容部分,同上,和菜单部分同一个层级,被SlidingMenu控件包裹。不难发现我们的效果中,打开菜单时,内容区域会有一个黑色遮罩,根据滑动的距离从透明到半透明,所以内容部分还有一个遮罩。

xml布局

总布局

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">

android:id="@+id/sliding_menu"

android:layout_width="match_parent"

android:layout_height="match_parent">

菜单布局

android:id="@+id/menu_list"

android:layout_width="280dp"

android:layout_height="match_parent" />

内容布局

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:layout_width="match_parent"

android:layout_height="match_parent"

android:orientation="vertical">

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" />

android:id="@+id/content_list"

android:layout_width="match_parent"

android:layout_height="match_parent" />

android:id="@+id/content_bg"

android:layout_width="match_parent"

android:layout_height="match_parent"

android:alpha="0"

android:background="#8C000000"

tools:alpha="1" />

行为分析

打开菜单

菜单从左向右拉出,菜单跟随内容一起向右移动。

内容从左侧拉到完全出现,不能继续拉,内容也不能继续拉。

关闭菜单

内容从右向左拉回,菜单跟随内容一起向左移动。

内容从右向左移动,完全移出屏幕时,内容不能继续向左拉。

其他细节

拽托菜单和内容都可以拉动整体菜单的移动。

遮罩根据拽托的位置,形成一个比值,让遮罩的透明度改变。

菜单打开时,比值为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个点会比较费脑子外,其他计算倒还好。

相关推荐