Android自定义View之仿QQ侧滑菜单实现

最近,由于正在做的一个应用中要用到侧滑菜单,所以通过查资料看视频,学习了一下自定义View,实现一个类似于QQ的侧滑菜单,顺便还将其封装为自定义组件,可以实现类似QQ的侧滑菜单和抽屉式侧滑菜单两种菜单。

下面先放上效果图:

侧滑菜单
抽屉式侧滑

我们这里的侧滑菜单主要是利用HorizontalScrollView来实现的,基本的思路是,一个布局中左边是菜单布局,右边是内容布局,默认情况下,菜单布局隐藏,内容布局显示,当我们向右侧滑,就会将菜单拉出来,而将内容布局的一部分隐藏,如下图所示:

这里写图片描述

下面我们就一步步开始实现一个侧滑菜单。

一、定义一个类SlidingMenu继承自HorizontalScrollView

我们后面所有的逻辑都会在这个类里面来写,我们先写上其构造方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class SlidingMenu extends HorizontalScrollView {
/**
* 在代码中使用new时会调用此方法
* @param context
*/
public SlidingMenu(Context context) {
this(context, null);
}

/**
* 未使用自定义属性时默认调用
* @param context
* @param attrs
*/
public SlidingMenu(Context context, AttributeSet attrs) {
//调用三个参数的构造方法
this(context, attrs, 0);

}

/**
* 当使用了自定义属性时会调用此方法
* @param context
* @param attrs
* @param defStyleAttr
*/
public SlidingMenu(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
}

二、定义菜单布局文件

left_menu.xml文件代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:orientation="vertical">

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="20dp"
android:layout_marginLeft="20dp"
android:gravity="left|center"
android:drawableLeft="@mipmap/ic_launcher"
android:text="第一个Item"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="20dp"
android:layout_marginLeft="20dp"
android:gravity="left|center"
android:drawableLeft="@mipmap/ic_launcher"
android:text="第二个Item"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="20dp"
android:layout_marginLeft="20dp"
android:gravity="left|center"
android:drawableLeft="@mipmap/ic_launcher"
android:text="第三个Item"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="20dp"
android:layout_marginLeft="20dp"
android:gravity="left|center"
android:drawableLeft="@mipmap/ic_launcher"
android:text="第四个Item"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="20dp"
android:layout_marginLeft="20dp"
android:gravity="left|center"
android:drawableLeft="@mipmap/ic_launcher"
android:text="第五个Item"/>
</LinearLayout>
</RelativeLayout>

上面其实就是定义了一列TextView来模仿菜单的Item项

三、定义主布局文件,使用自定义的View

activity_main.xml文件代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!--自定义View-->
<com.codekong.qq_50_slidingmenu.view.SlidingMenu
android:layout_width="match_parent"
android:layout_height="match_parent"
app:rightPadding="100dp"
app:drawerType="false"
android:scrollbars="none">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal">
<!--引入菜单布局-->
<include layout="@layout/left_menu"/>
<!--内容布局-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/qq_bg">

</LinearLayout>
</LinearLayout>
</com.codekong.qq_50_slidingmenu.view.SlidingMenu>
</RelativeLayout>

四、自定义成员变量

我们定义一些成员变量以便于后面使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//自定义View布局中内嵌的第一层的LinearLayout
private LinearLayout mWapper;
//菜单布局
private ViewGroup mMenu;
//内容布局
private ViewGroup mContent;
//屏幕宽度
private int mScreenWidth;
//菜单距屏幕右侧的距离,单位dp
private int mMenuRightPadding = 50;
//菜单的宽度
private int mMenuWidth;
//定义标志,保证onMeasure只执行一次
private boolean once = false;
//菜单是否是打开状态
private boolean isOpen = false;

五、拿到屏幕宽度的像素值

因为目前为止,我们没有使用自定义属性,所以自定义View默认会调用两个参数的构造方法,但因为我们第一步中写构造方法时是在两个参数的构造方法中调用了三个参数的构造方法,所以,我们将获取屏幕宽度的代码写在三个参数的构造方法中,后面我们自定义属性后获取属性值也是在三个参数的构造方法中书写相应的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 当使用了自定义属性时会调用此方法
* @param context
* @param attrs
* @param defStyleAttr
*/
public SlidingMenu(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//通过以下步骤拿到屏幕宽度的像素值
WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics displayMetrics = new DisplayMetrics();
windowManager.getDefaultDisplay().getMetrics(displayMetrics);
mScreenWidth = displayMetrics.widthPixels;
}

六、实现onMeasure()方法

onMeasure()方法是自定义View的正式第一步,它用来决定内部View(子View)的宽和高,以及自身的宽和高,下面是具体的代码逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 设置子View的宽和高
* 设置自身的宽和高
* @param widthMeasureSpec
* @param heightMeasureSpec
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (!once){
once = true;
mWapper = (LinearLayout) getChildAt(0);
mMenu = (ViewGroup) mWapper.getChildAt(0);
mContent = (ViewGroup) mWapper.getChildAt(1);
//菜单和内容区域的高度都可以保持默认match_parent
//菜单宽度 = 屏幕宽度 - 菜单距屏幕右侧的间距
mMenuWidth = mMenu.getLayoutParams().width = mScreenWidth - mMenuRightPadding;
mContent.getLayoutParams().width = mScreenWidth;
//当设置了其中的菜单的宽高和内容区域的宽高之后,最外层的LinearLayout的mWapper就自动设置好了
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

七、实现onLayout()方法

onLayout()方法中主要是确定自定义View中子View放置的位置。下面是具体的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 通过设置偏移量将Menu隐藏
* @param changed
* @param l
* @param t
* @param r
* @param b
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {

super.onLayout(changed, l, t, r, b);
if (changed){
//布局发生变化时调用(水平滚动条向右移动menu的宽度,则正好将menu隐藏)
this.scrollTo(mMenuWidth, 0);
}
}

这个比较好理解,由于我们使用的是水平滚动布局,我们默认情况下相当于将水平滚动条向右拖动菜单宽度的的距离,这样左边布局的菜单就正好被隐藏了。

八、onTouchEvent()方法

该方法主要处理内部内部View的移动,我们可以在其中写一些逻辑控制自定义View内部的滑动事件。
由于我们的自定义View是继承自HorizontalScrollView,我们不再处理按下和移动事件,保持HorizontalScrollView默认的即可,但对于手指抬起事件,我们需要根据手指在水平X轴方向的位移来做出打开菜单或关闭菜单的操作,所以我们的逻辑代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public boolean onTouchEvent(MotionEvent ev) {
int action = ev.getAction();
//按下和移动使用HorizontalScrollView的默认处理
switch (action){
case MotionEvent.ACTION_UP:
//隐藏在左边的位置
int scrollX = getScrollX();
if (scrollX > mMenuWidth / 2){
//隐藏的部分较大, 平滑滚动不显示菜单
this.smoothScrollTo(mMenuWidth, 0);
isOpen = false;
}else{
//完全显示菜单
this.smoothScrollTo(0, 0);
isOpen = true;
}
return true;
}
return super.onTouchEvent(ev);
}

上面最难理解的的就是getScrollX(),它指的是菜单隐藏未显示的那部分的宽度。关于详细的解释,大家可以去看源码,也可以去看看这篇博客 图解Android View的scrollTo(),scrollBy(),getScrollX(), getScrollY(),讲的很清楚。

其实到这一步为止,一个基本的侧滑菜单已经做出来了,下面我们将使用属性动画对我们的自定义View进行扩展,使其实现最开始展示的抽屉式侧滑菜单。

九、属性动画实现抽屉式侧滑

接下来我们实现抽屉式侧滑,抽屉式侧滑说白了就是,我们的菜单不是一点点被拉出来,而是看起来菜单就藏在页面的背后,随着我们向右滑动,一点点显露出来。
实现的思路很简单,当我们拖动时,我们让菜单布局的偏移量等于getScrollX()的值,也就是时刻把菜单隐藏在左边的部分向右偏移出来,这样我们看起来就像菜单藏在页面后面。如下图:

这里写图片描述

当我们左右滑动时会触发onScrollChanged()方法,我们在此处算出菜单需要的实时的偏移量,然后调用属性动画即可。
下面说说具体实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 滚动发生时调用
* @param l getScrollX()
* @param t
* @param oldl
* @param oldt
*/
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
float scale = l * 1.0f / mMenuWidth; //1 ~ 0
//调用属性动画,设TranslationX
mMenu.setTranslationX(mMenuWidth * scale);
}

上面方法中的 l 就是上一步中提到的getScrollX()获得的值。当我们没有拉菜单时,菜单需要的偏移量就是整个菜单的宽度,当我们将菜单完全拉出时,菜单就不需要偏移量了,此时偏移量为0。此时我们的抽屉式侧滑就做好了。

注:此处的属性动画是在Android3.0之后引入的,如果需要兼容更早的版本,可以用相关的兼容库。

十、自定义属性实现灵活配置

自定义属性主要是方便使用者可以根据具体的场景实现不同的效果。比如,我们可以通过在xml文件中配置,实现菜单是普通的侧滑式还是抽屉式。在刚开始,我们在自定义View中将菜单打开时,菜单右边缘距离屏幕右边缘的值设置为50dp,我们通过自定义属性可以实现在xml文件中自己配置合适的值。

自定义属性按下面的步骤进行:

1 . 在 res/values 目录下新建attr.xml文件,文件中写入的内容如下:

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="rightPadding" format="dimension"/>
<attr name="drawerType" format="boolean"/>
<declare-styleable name="SlidingMenu">
<attr name="rightPadding"/>
<attr name="drawerType"/>
</declare-styleable>
</resources>

上面的比较好理解,我们先在上面声明两个自定义的属性的名称及其对应的类型,然后再在下面的具体的自定义样式中引用它们。上面两个自定义的属性分别是菜单拉开时右边缘距离屏幕右边缘的距离,以及菜单是否是抽屉式布局。

2 . 在自定义View类中获取到自定义的属性值。如果用户在xml文件中自定义了属性值,我们则获取,如果没有显式设置,则使用默认值即可。

顺便说一下,前面提到当我们使用自定义属性时,会默认调用三个参数的构造方法,所以我们获取自定义属性值的代码也是写在三个参数的构造方法中。

下面是获取属性值的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 当使用了自定义属性时会调用此方法
* @param context
* @param attrs
* @param defStyleAttr
*/
public SlidingMenu(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

//获取我们自定义的属性
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs,
R.styleable.SlidingMenu, defStyleAttr, 0);
int n = typedArray.getIndexCount();
//遍历每一个属性
for (int i = 0; i < n; i++) {
int attr = typedArray.getIndex(i);
switch (attr){
//对我们自定义属性的值进行读取
case R.styleable.SlidingMenu_rightPadding:
//如果在应用样式时没有赋值则使用默认值50,如果有值则直接读取
mMenuRightPadding = typedArray.getDimensionPixelSize(attr,
(int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, mMenuRightPadding, context.getResources().getDisplayMetrics()));
break;
case R.styleable.SlidingMenu_drawerType:
isDrawerType = typedArray.getBoolean(attr, false);
break;
default:
break;
}
}
//释放,一定要释放
typedArray.recycle();
}

3 . 上面的代码中我们已经可以读取到设置的属性值,我们可以如下面一样设置自定义属性值:

1
2
3
4
5
6
7
<com.codekong.qq_50_slidingmenu.view.SlidingMenu
android:layout_width="match_parent"
android:layout_height="match_parent"
app:rightPadding="100dp"
app:drawerType="true"
android:scrollbars="none">
</com.codekong.qq_50_slidingmenu.view.SlidingMenu>

4 . 使用属性值控制具体的逻辑,我们的rightPadding一旦获取到就会在onMeasure()方法中被设置,而drawerType被获取到就可以控制是否会调用onScrollChanged()中的代码。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (!once){
once = true;
mWapper = (LinearLayout) getChildAt(0);
mMenu = (ViewGroup) mWapper.getChildAt(0);
mContent = (ViewGroup) mWapper.getChildAt(1);
//菜单和内容区域的高度都可以保持默认match_parent
//菜单宽度 = 屏幕宽度 - 菜单距屏幕右侧的间距
mMenuWidth = mMenu.getLayoutParams().width = mScreenWidth - mMenuRightPadding;
mContent.getLayoutParams().width = mScreenWidth;
//当设置了其中的菜单的宽高和内容区域的宽高之后,最外层的LinearLayout的mWapper就自动设置好了
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
1
2
3
4
5
6
7
8
9
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if (isDrawerType){
float scale = l * 1.0f / mMenuWidth; //1 ~ 0
//调用属性动画,设TranslationX
mMenu.setTranslationX(mMenuWidth * scale);
}
}

十一、给自定义View设置方法

对于我们的滑动式菜单,我们最常用的功能便是菜单的打开和关闭,所以我们可以在自定义View中定义这两个方法,方便我们的使用,下面是具体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 打开菜单
*/
public void openMenu(){
if (!isOpen){
this.smoothScrollTo(0, 0);
isOpen = true;
}
}

/**
* 关闭菜单
*/
public void closeMenu(){
if (isOpen){
this.smoothScrollTo(mMenuWidth, 0);
isOpen = false;
}
}

/**
* 切换菜单
*/
public void toggleMenu(){
if (isOpen){
closeMenu();
}else{
openMenu();
}
}

当我们在Activity中使用时可以按下面的代码使用:

1
2
SlidingMenu slidingMenu = (SlidingMenu) findViewById(R.id.sliding_menu);
slidingMenu.toggleMenu();

最后面放上完整的自定义View的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
public class SlidingMenu extends HorizontalScrollView {
//自定义View布局中内嵌的最外层的LinearLayout
private LinearLayout mWapper;
//菜单布局
private ViewGroup mMenu;
//内容布局
private ViewGroup mContent;
//屏幕宽度
private int mScreenWidth;
//菜单距屏幕右侧的距离,单位dp
private int mMenuRightPadding = 50;
//菜单的宽度
private int mMenuWidth;
//定义标志,保证onMeasure只执行一次
private boolean once = false;
//菜单是否是打开状态
private boolean isOpen = false;
//是否是抽屉式
private boolean isDrawerType = false;
/**
* 在代码中使用new时会调用此方法
* @param context
*/
public SlidingMenu(Context context) {
this(context, null);
}

/**
* 未使用自定义属性时默认调用
* @param context
* @param attrs
*/
public SlidingMenu(Context context, AttributeSet attrs) {
//调用三个参数的构造方法
this(context, attrs, 0);

}

/**
* 当使用了自定义属性时会调用此方法
* @param context
* @param attrs
* @param defStyleAttr
*/
public SlidingMenu(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

//获取我们自定义的属性
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs,
R.styleable.SlidingMenu, defStyleAttr, 0);
int n = typedArray.getIndexCount();
//遍历每一个属性
for (int i = 0; i < n; i++) {
int attr = typedArray.getIndex(i);
switch (attr){
//对我们自定义属性的值进行读取
case R.styleable.SlidingMenu_rightPadding:
//如果在应用样式时没有赋值则使用默认值50,如果有值则直接读取
mMenuRightPadding = typedArray.getDimensionPixelSize(attr,
(int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, mMenuRightPadding, context.getResources().getDisplayMetrics()));
break;
case R.styleable.SlidingMenu_drawerType:
isDrawerType = typedArray.getBoolean(attr, false);
break;
default:
break;
}
}
//释放
typedArray.recycle();

//通过以下步骤拿到屏幕宽度的像素值
WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics displayMetrics = new DisplayMetrics();
windowManager.getDefaultDisplay().getMetrics(displayMetrics);
mScreenWidth = displayMetrics.widthPixels;
}

/**
* 设置子View的宽和高
* 设置自身的宽和高
* @param widthMeasureSpec
* @param heightMeasureSpec
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (!once){
once = true;
mWapper = (LinearLayout) getChildAt(0);
mMenu = (ViewGroup) mWapper.getChildAt(0);
mContent = (ViewGroup) mWapper.getChildAt(1);
//菜单和内容区域的高度都可以保持默认match_parent
//菜单宽度 = 屏幕宽度 - 菜单距屏幕右侧的间距
mMenuWidth = mMenu.getLayoutParams().width = mScreenWidth - mMenuRightPadding;
mContent.getLayoutParams().width = mScreenWidth;
//当设置了其中的菜单的宽高和内容区域的宽高之后,最外层的LinearLayout的mWapper就自动设置好了
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

/**
* 通过设置偏移量将Menu隐藏
* @param changed
* @param l
* @param t
* @param r
* @param b
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {

super.onLayout(changed, l, t, r, b);
if (changed){
//布局发生变化时调用(水平滚动条向右移动menu的宽度,则正好将menu隐藏)
this.scrollTo(mMenuWidth, 0);
}
}

@Override
public boolean onTouchEvent(MotionEvent ev) {
int action = ev.getAction();
//按下和移动使用HorizontalScrollView的默认处理
switch (action){
case MotionEvent.ACTION_UP:
//隐藏在左边的位置
int scrollX = getScrollX();
if (scrollX > mMenuWidth / 2){
//隐藏的部分较大, 平滑滚动不显示菜单
this.smoothScrollTo(mMenuWidth, 0);
isOpen = false;
}else{
//完全显示菜单
this.smoothScrollTo(0, 0);
isOpen = true;
}
return true;
}
return super.onTouchEvent(ev);
}

/**
* 打开菜单
*/
public void openMenu(){
if (!isOpen){
this.smoothScrollTo(0, 0);
isOpen = true;
}
}

/**
* 关闭菜单
*/
public void closeMenu(){
if (isOpen){
this.smoothScrollTo(mMenuWidth, 0);
isOpen = false;
}
}

/**
* 切换菜单
*/
public void toggleMenu(){
if (isOpen){
closeMenu();
}else{
openMenu();
}
}

/**
* 滚动发生时调用
* @param l getScrollX()
* @param t
* @param oldl
* @param oldt
*/
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if (isDrawerType){
float scale = l * 1.0f / mMenuWidth; //1 ~ 0
//调用属性动画,设TranslationX
mMenu.setTranslationX(mMenuWidth * scale);
}
}
}
如果博客对您有帮助,不妨请我喝杯咖啡...