HorizontalScrollView高仿QQ滑动删除

不论什么领域,在模仿一个东西的时候,我们首先要对它进行需求分析,这样才能保证做到”惟妙惟肖”。下面我们一步一步来实现QQ的侧滑功能。

需求:

  1. 每个Item都可以侧滑,根据Item类型的不同,侧滑后显示的菜单项也不同(联系人/群组的菜单有:置顶,标为已读,删除, 通知类消息展示的菜单只有置顶和删除);
  2. 侧滑的过程中,如果滑动距离超过第一个菜单的宽度,抬起手指时会显示全部的菜单,即Item会滑动到最左端;
  3. 在向右滑动关闭菜单的过程中,如果滑动距离超过最后一个菜单的宽度,抬起手指时会关闭全部菜单, 即Item会恢复至正常展示状态;
  4. 如果Item的菜单呈展开状态,则点击此Item或按下其他Item,当前的Item的菜单将会关闭;
  5. 如果没有Item的菜单呈展开状态,点击Item时将进入聊天页面;
  6. 观察Item滑动的过程,发现其是匀速滑动, 而不是快速移动;
  7. 不能同时滑动多个Item;

通过对需求的分析,首先会想到HorizontalScrollView, 当然,重写Item的RooView的onTouchListener()也可以实现,但是普通的View只有scrollTo()和scrollBy()方法, 只能快速移动而不能匀速移动,导致滑动的过程很生硬。所以我们使用HorizontalScrollView来实现我们的效果。

布局:

根布局其实没什么内容,就是一个ListView,这样就不贴代码了, 下面我们主要展示一下Item的布局内容:

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
<?xml version="1.0" encoding="utf-8"?>
<HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/horizontal_scrollview"
android:layout_width="wrap_content"
android:layout_height="70dp"
android:fillViewport="true"
android:scrollbars="none">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center">
<LinearLayout
android:id="@+id/content_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:padding="12dp">
<ImageView
android:id="@+id/icon"
android:layout_width="60dp"
android:layout_height="60dp"
android:src="@mipmap/ic_launcher"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginLeft="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/name_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Freeman"
android:textSize="15sp"
android:textColor="#333333"/>
<TextView
android:id="@+id/content_text"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="bottom"
android:text="HorizontalScrollView实现QQ侧滑删除"
android:textSize="14sp"
android:textColor="#999999"/>
</LinearLayout>
<TextView
android:id="@+id/time_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingRight="12dp"
android:text="14:06"
android:textSize="13sp"
android:textColor="#999999"/>
</LinearLayout>
<TextView
android:layout_width="70dp"
android:layout_height="match_parent"
android:gravity="center"
android:text="置顶"
android:textSize="15sp"
android:textColor="#FFFFFF"
android:background="#999999"/>
<TextView
android:layout_width="85dp"
android:layout_height="match_parent"
android:gravity="center"
android:text="标为已读"
android:textSize="15sp"
android:textColor="#FFFFFF"
android:background="#FF9900"/>
<TextView
android:layout_width="70dp"
android:layout_height="match_parent"
android:gravity="center"
android:text="删除"
android:textSize="15sp"
android:textColor="#FFFFFF"
android:background="#FF0000"/>
</LinearLayout>
</HorizontalScrollView>

在使用HorizontalScrollView的时候有以下几点需要注意:

  1. 它和ScrollView一样,只能有一个子布局,且一般为LinearLayout;
  2. 只有内容的宽度超过屏幕的宽度HorizontalScrollView才可以滑动;
  3. match_parent会失效。默认情况下HorizontalScrollView使用match_parent,如果内容没有占满屏幕宽度,则HorizontalScrollView的宽度不会达到屏幕的宽度,要想让HorizontalScrollView和屏幕宽度一样,需要添加 android:fillViewport=”true”属性。同样其子布局的match_parent也会失效,在这个布局中,id为content_layout的LinearLayout宽度为match_parent, 刚开始我认为它已经占满了整个屏幕的宽度,然后后面的菜单按键就会在屏幕之外,这样整个布局的跨度超过了屏幕宽度,HorizontalScrollView就可以自动滑动了,结果是我想多了;

实现:

step1. 让HorizontalScrollView滑动起来

加载上面的布局,然后设置给ListView设置了布局之后发现HorizontalView根本不能滑动,问题当然很明确,Item布局中match_paren失效,既然布局中不能设置,那我们只能动态设置了,通过以下代码设置Item中展示内容的布局宽度为屏幕宽度:

1
2
3
4
5
6
// 获取内容展示布局的布局参数
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) holder.contentLayout.getLayoutParams();
// 设置宽度为屏幕宽度
params.width = getResources().getDisplayMetrics().widthPixels;
// 重设布局参数
holder.contentLayout.setLayoutParams(params);

再次运行发现HorizontalScrollView可以滑动了,看起来效果还不错,但这只是一个开始。仔细分析一下上面其他的需求,我们发现即便使用HorizontalScrollView, 却依然需要重写onTouchListener()才能实现。接下来我们一步一步来实现其他的需求:

step2. 不要在路上逗留

需求2和需求3主要是对滑动距离的控制, 那么只要我们在手指弹起的时候对滑动距离进行判断,就可以很好地对其进行控制。代码实现如下:

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
// 获取滑动的距离
float distance = Math.abs(event.getX() - downX);
if (distance > 0 && distance < dpToPx(70)) { // 如果滑动距离在70dp内(Button宽度)则回复至原来的位置
v.post(new Runnable() {
@Override
public void run() {
// 如果是向右滑动,则依然恢复至菜单展开状态
if (isRight) {
((HorizontalScrollView) view).fullScroll(View.FOCUS_RIGHT);
} else {
((HorizontalScrollView)view).fullScroll(View.FOCUS_LEFT);
}
}
});
} else { // 滑动距离超过70dp(Button宽度)则完全展开菜单或关闭菜单
v.post(new Runnable() {
@Override
public void run() {
if (isRight) {
((HorizontalScrollView) view).fullScroll(View.FOCUS_LEFT);
} else {
lastPosition = position;
((HorizontalScrollView)view).fullScroll(View.FOCUS_RIGHT);
}
}
});
}

fullScroll()用来滑动至两端,View.FOCUS_LEFT表示滑动至最左端,即恢复至正常显示状态,View.FOCUS_RIGHT表示滑动至最右段,即展开菜单。但是直接使用fullScroll()是不起作用的,因为fullScroll()的操作是异步的,系统并不会等待fullScroll()执行完成,所以我们需要使用post将其添加到消息队列中(因为Android是通过消息队列来实现同步的)同步操作。

step3. 出来混,早晚要回去的

有菜单展开的情况,那在某种操作下必然要恢复正常,即需求4的描述。如果点击已展开的Item,则关闭此Item,如果按下其他的Item,则关闭已展开的Item,通过对需求的理解,我们可以得出需要处理点击事件和按下事件。具体代码如下:

1
2
3
4
5
6
7
8
9
10
// 处理按下其他Item则关闭已展开的Item的情况(lastPosition表示已展开的Item position)
if (lastPosition != -1 && lastPosition != position) {
// 获取已展开的Item的RootView
View openedItemView = getViewByPosition(listView, lastPosition);
if (openedItemView != null) {
final HorizontalScrollView horizontalScrollView = ((HorizontalScrollView)openedItemView.findViewById(R.id.horizontal_scrollview));
// 将已展开的Item置位
horizontalScrollView.smoothScrollTo(0, 0);
}
}

需要注意的是:smoothScrollTo()是同步操作,直接使用就可以。这里需要说明一下获取ListView指定position的ItemView的实现:

1
2
3
4
5
6
7
8
9
10
11
12
private View getViewByPosition(ListView listView, int position) {
// 获取当前可见的第一个Item的position
int firstItemPos = listView.getFirstVisiblePosition();
// 获取最后一个可见的Item的position
int lastItemPos = firstItemPos + listView.getChildCount() - 1;
if (position < firstItemPos || position > lastItemPos) {
return listView.getAdapter().getView(position, null, listView);
} else {
int childIndex = position - firstItemPos;
return listView.getChildAt(childIndex);
}
}

刚开始获取指定position的ItemView的时候使用了listView.getChildAt(childIndex), 结果在滑动到下一页的时候,点击Item就出现空指针的情况,看了下getChildAt()函数的源码,发现其返回的是只当前页可见的Item, 当然, getChildCount()返回也是当前页可见的Item的数量。

处理完了第一种情况,接下来我们处理点击已展开的Item的情况,这个问题其实很明显,我们只需要在UP事件中处理即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 获取滑动距离(区分滑动和点击)
float distance = Math.abs(event.getX() - downX);
if (distance == 0.0) {
//点击已展开的Item的情况
if (lastPosition == position) {
v.post(new Runnable() {
@Override
public void run() {
((HorizontalScrollView)view).fullScroll(View.FOCUS_LEFT);
lastPosition = -1;
}
});
} else if (lastPosition == -1) {
// 没有Item展开,点击时直接响应点击事件
Toast.makeText(MainActivity.this, "触发了点击事件", Toast.LENGTH_SHORT).show();
} else {
// 对按下其他Item导致已展开的Item关闭的情况,对lastPosition进行置位
lastPosition = -1;
}
}

step4. 有条不紊

现在可以尝试我们的滑动删除了,但是发现竟然支持可以多点触控,即可以侧滑多个Item。So easy! 只要我们关闭listView的多点触控即可解决此问题。在父布局的ListView中添加如下属性即可:

1
android:splitMotionEvents="false"

至此,我们通过HorizontalScrollView实现了QQ侧滑删除的全部需求(通过不同的Item加载不同的菜单很简单,根据类型对菜单项进行显示与隐藏即可)。

效果:

源码:

为了缩小文章的篇幅,在此我们只展示Adapter的getView的代码:

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
@Override
public android.view.View getView(final int position, android.view.View convertView, final ViewGroup parent) {
ViewHolder holder;
if (convertView == null) {
holder = new ViewHolder();
convertView = getLayoutInflater().inflate(R.layout.item_layout, parent, false);
holder.icon = (ImageView) convertView.findViewById(R.id.icon);
holder.nameText = (TextView) convertView.findViewById(R.id.name_text);
holder.contentText = (TextView) convertView.findViewById(R.id.content_text);
holder.timeText = (TextView) convertView.findViewById(R.id.time_text);
holder.contentLayout = (LinearLayout) convertView.findViewById(R.id.content_layout);
holder.horizontalScrollView = (HorizontalScrollView) convertView.findViewById(R.id.horizontal_scrollview);
convertView.setTag(holder);
} else {
holder = (ViewHolder) convertView.getTag();
}

LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) holder.contentLayout.getLayoutParams();
params.width = getResources().getDisplayMetrics().widthPixels;
holder.contentLayout.setLayoutParams(params);
holder.icon.setImageResource(data.get(position).icon);
holder.nameText.setText(data.get(position).name);
holder.contentText.setText(data.get(position).content);
holder.timeText.setText(data.get(position).time);

holder.horizontalScrollView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
final View view = v;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
if (lastPosition != -1 && lastPosition != position) {
View openedItemView = getViewByPosition(listView, lastPosition);
if (openedItemView != null) {
final HorizontalScrollView horizontalScrollView = ((HorizontalScrollView)openedItemView.findViewById(R.id.horizontal_scrollview));
horizontalScrollView.smoothScrollTo(0, 0);
}
}
break;
case MotionEvent.ACTION_MOVE:
if (event.getX() > lastXOffset) {
isRight = true;
} else {
isRight = false;
}
lastXOffset = event.getX();
break;
case MotionEvent.ACTION_UP:
float distance = Math.abs(event.getX() - downX);
if (distance == 0.0) {
if (lastPosition == position) {
v.post(new Runnable() {
@Override
public void run() {
((HorizontalScrollView)view).fullScroll(View.FOCUS_LEFT);
lastPosition = -1;
}
});
} else if (lastPosition == -1) {
Toast.makeText(MainActivity.this, "触发了点击事件", Toast.LENGTH_SHORT).show();
} else {
lastPosition = -1;
}
} else if (distance > 0 && distance < dpToPx(70)) {
v.post(new Runnable() {
@Override
public void run() {
if (isRight) {
((HorizontalScrollView) view).fullScroll(View.FOCUS_RIGHT);
} else {
((HorizontalScrollView)view).fullScroll(View.FOCUS_LEFT);
}
}
});
} else {
v.post(new Runnable() {
@Override
public void run() {
if (isRight) {
((HorizontalScrollView) view).fullScroll(View.FOCUS_LEFT);
} else {
lastPosition = position;
((HorizontalScrollView)view).fullScroll(View.FOCUS_RIGHT);
}
}
});
}
break;
default:
break;
}

return false;
}
});

return convertView;
}
}

源码下载:

下载地址:HorizontalScrollView仿QQ侧滑删除Demo

,