UI--学习模仿QQ未读提醒拖拽删除

《代码里的世界》UI篇

用文字札记描绘自己 android学习之路

转载请保留出处 by Qiao
http://blog.csdn.net/qiaoidea/article/details/46608385

【导航】


1.概述

  作为一款优秀的社交聊天软件,QQ始终保持着优秀的交互与设计,同时引领不少新时尚与标准规范,而它同时也有一些人性化的设计颇值得为人称道。今天要提到的是QQ消息未读拖拽清除(一键退朝”,“一键清除未读”,“一键下班”)的功能。具体细节参考 知乎:一键消除红点功能是怎么想出来的?当然,得益于诸位大大的各种尝试,小弟也稍加模仿修改了一个类似的Demo.这里展示下我们最后实现的各种样式及效果图:
  
  QQ未读提醒拖拽删除效果图
  
  (注:部分设计思想借鉴 chenupt的博文,不过其部分细节和实用性不够友善,拖动区域小,移除效果不随手指移动等原因,特作修缮并加一个人理解稍作补充。)
  示例源码 已更新上传,包涵了滑动切换fragment和底部tab变色效果。具体实现方案鸿神已经提过,有兴趣的点个赞我后边再另开一篇仔细啰嗦下。


2.设计实现

  具体原理分析请看前边贴的知乎原文,实现的话稍作讲解下吧。
  先贴老图:
  贝塞尔曲线模型
  1.以未读消息原图中心为原点,计算p1~p4四点坐标,根据未读红点和手势拖动点间的距离来判断红点的消除与回弹。
  2.初始化消除动画view,关联到指定未读view并绑定拖动事件,根据手势位置更新图片坐标。
  3.判断是否超出最大距离,并根据连接/断开状态来处理手势释放事件,看是否需要消除view。
  4.在上述各状态时绑定相应事件。
  
  貌似很多同学更关注如何使用,因此从本篇开始优先讲引入与使用。

2.1如何使用

  • 导入 示例源码

    1.直接导入tipsview至项目作为库/(或直接引入到自己项目)
    (1)Android Studio 在项目 build.gradle 中配置

    compile project(“:tipsview”)

    (2)eclipse 直接 add library(或在 project.properties 配置)

    android.library.reference.1=../tipsview

  • 使用

    1.将TipsView添加至layout.xml 布局最顶层
1
2
3
4
<code.qiao.com.tipsview.TipsView
android:id="@+id/tip"
android:layout_width="match_parent"
android:layout_height="match_parent"/>

或者

1
rootView.addView(tipview, LayoutParams.MATCH_PARENT,LayoutParams.MATCH_PARENT);

2.关联至指定可拖动view,并实现拖动响应事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
tipsView.attach(targetView, new TipsView.Listener(){
@Override
public void onStart() {
targetView.setVisibility(View.INVISIBLE);
}

@Override
public void onComplete() {

}

@Override
public void onCancel() {
targetView.setVisibility(View.VISIBLE);
}
});

如果是添加在listView等有触摸事件处理作为子view的地方,记得在onStart()方法中调用

1
2
//当requestDisallowInterceptTouchEvent 参数为true的时候 它不会拦截其子控件的 触摸事件
listView.requestDisallowInterceptTouchEvent(true);

方法说明

1
2
3
4
//缺省方法
attach(final View attachView, Listener listener)

attach(final View attachView, final Func<View> copyViewCreator, final Listener listener)

其中

  • View attachView 为点击拖动目标view,比如显示消息未读的view
  • Func copyViewCreator 点击拖动时候显示的View,缺省方法默认显示被拖动view本身,当然可以返回其他view,比如选中弹出另外一个view样式。

重写invoke()方法返回拖动显示的view

1
2
3
4
5
6
new TipsView.Func<View>() {
@Override
public View invoke() {
return null;//返回要显示view
}
}
  • Listener listener 点击拖动开始,完成(即消除),取消事件接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    new TipsView.Listener(){
    @Override
    public void onStart() {
    //开始拖动
    }

    @Override
    public void onComplete() {
    //拖动并移除后
    }

    @Override
    public void onCancel() {
    //拖动取消
    }
    });

实现上述接口便可以达到类似QQ拖动清除效果。

2.2 详细实现

  整个View大概用了不到300行代码,原理除了计算和利用贝塞尔曲线绘制拖拽效果外,并无什么特别复杂的逻辑。这里从源码层面简要走一遍。

1.全局变量参数

  定义记录各个位置的坐标点 ,初始化画笔,半径和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 class TipsView extends FrameLayout {
//默认半径
public static final float DEFAULT_RADIUS = 20;

//画笔与路径
private Paint paint;

//当前拖动位置
float x = 0;
float y = 0;

//贝塞尔曲线的操作点
float anchorX = 0;

//相对于view起点
float startX = 500;
float startY = 100;

//相对于屏幕的起点位置
float thisX = 0;
float thisY = 0;

float radius = DEFAULT_RADIUS;//当前半径

boolean isTrigger, isTouch;//是否触发消失动画,是否手势触摸状态

ImageView exploredImageView;//带有消失动画效果的imageview
View tipImageView; //可拖动的View

//。。。

2.初始化

  因为是显示在最顶层,用于展示拖动View,所以初始化时候设置背景透明,并设置画笔和添加带有消失动画效果的imageview(默认不可见)。

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
public TipsView(Context context) {
super(context);
init();
}

public TipsView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}

public TipsView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}

private void init() {
//透明背景
setBackgroundColor(Color.TRANSPARENT);
path = new Path();//轨迹

//初始化画笔
paint = new Paint();
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.FILL_AND_STROKE);
paint.setStrokeWidth(2);
paint.setColor(0xffed5050);

//添加消失效果的View
LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
exploredImageView = new ImageView(getContext());
exploredImageView.setLayoutParams(params);
exploredImageView.setImageResource(R.drawable.tips_bubble);
exploredImageView.setVisibility(View.INVISIBLE);
addView(exploredImageView);
}

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
private void calculate() {
//计算两点(拖动位置和起点)距离,得到当前圆点半径
float distance = (float) Math.sqrt(Math.pow(y - startY, 2) + Math.pow(x - startX, 2));
radius = -distance / 15 + DEFAULT_RADIUS;

if (radius < 7) { //当半径小于指定值时候触发消除动画
isTrigger = true;
} else {
isTrigger = false;
}

/**
*近似地将两个圆起始半径设置相等,简单地求与圆心线垂直的线和圆的相交的四个点
*/

float offsetX = (float) (radius * Math.sin(Math.atan((y - startY) / (x - startX))));
float offsetY = (float) (radius * Math.cos(Math.atan((y - startY) / (x - startX))));

float x1 = startX - offsetX;
float y1 = startY + offsetY;

float x2 = x - offsetX;
float y2 = y + offsetY;

float x3 = x + offsetX;
float y3 = y - offsetY;

float x4 = startX + offsetX;
float y4 = startY - offsetY;

//计算轨迹
path.reset();
path.moveTo(x1, y1);
path.quadTo(anchorX, anchorY, x2, y2); //贝塞尔曲线
path.lineTo(x3, y3); //直线
path.quadTo(anchorX, anchorY, x4, y4);
path.lineTo(x1, y1);
}

@Override
protected void onDraw(Canvas canvas) {
calculate();//计算轨迹
if (isTrigger || !isTouch || tipImageView == null) { //断开或手势释放或展示view为空均只绘制透明图层
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.OVERLAY);
} else { //绘制透明图层后,接着绘制轨迹,并在起点和当前手势位置绘制两个圆
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.OVERLAY);
canvas.drawPath(path, paint);
canvas.drawCircle(startX, startY, radius, paint);
canvas.drawCircle(x, y, radius, paint);
}
super.onDraw(canvas);
}

4.关联到目标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
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
//缺省方法,默认使用目标view作为可拖动view
public void attach(final View attachView, Listener listener) {
attach(attachView,
new Func<View>() { //返回拖动时候显示的view
@Override
public View invoke() {
Bitmap bm = view2Bitmap(attachView);
ImageView iv = new ImageView(getContext());
iv.setImageBitmap(bm);
return iv;
}
}, listener);
}

//目标view,拖动时候展示的view(可定制)和监听事件
public void attach(final View attachView, final Func<View> copyViewCreator, final Listener listener) {
attachView.setOnTouchListener(new OnTouchListener() {

protected void init() { //初始化
/**
*获取目标view在屏幕中位置 和 可展示区域(TipView)在屏幕位置
*从而得到目标在展示区域的位置startX 和startY
*/

int[] attachLocation = new int[2];
attachView.getLocationOnScreen(attachLocation);
int[] thisLocation = new int[2];
TipsView.this.getLocationOnScreen(thisLocation);

//得到目标view在展示区域中的位置
startX = attachLocation[0] - thisLocation[0] + attachView.getWidth() / 2;
startY = attachLocation[1] - thisLocation[1] + attachView.getHeight() / 2;

//当前手势位置即起点
x = startX;
y = startY;

tipImageView = copyViewCreator.invoke(); //得到拖动时候要展示的View

//添加到展示区域中
tipImageView.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
TipsView.this.addView(tipImageView);
tipImageView.measure(0,0);//由于刚添加并没有测量和绘制,会导致娶不到高宽,影响展示view(中心与起点对齐),所以先进行一次测量

//绘制展示的view位置中心与起点重合
tipImageView.setX(startX - tipImageView.getMeasuredWidth() / 2);
tipImageView.setY(startY - tipImageView.getMeasuredHeight() / 2);

if (listener != null) {
listener.onStart(); //触发开始拖动事件
}
}

protected void destory() {
TipsView.this.removeView(tipImageView); //移除拖动展示的view
}

@Override
public boolean onTouch(View v, MotionEvent event) { //触摸事件监听
if (event.getAction() == MotionEvent.ACTION_DOWN) { //按下
init(); //初始化
isTouch = true;
int[] location = new int[2];
TipsView.this.getLocationOnScreen(location);
//相对于屏幕的起点位置
thisX = location[0];
thisY = location[1];

invalidate(); //重绘
return true;
}
//如果isTouch为false,不处理触摸事件
if (!isTouch)
return false;
if (event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL) { //手势移开或取消
isTouch = false;
destory(); //移除展示view

if (isTrigger) { //触发了消失动画
postDelayed(new Runnable() {
@Override
public void run() {
isTrigger = false;
if (listener != null) {
listener.onComplete();//调用移除完成事件
}
}
}, 1000);

//在手势释放处显示移除效果的View
exploredImageView.setX(x - exploredImageView.getWidth() / 2);
exploredImageView.setY(y - exploredImageView.getHeight() / 2);
exploredImageView.setVisibility(View.VISIBLE);

//取得移除效果view的帧动画背景,播放消失动画
exploredImageView.setImageResource(R.drawable.tips_bubble);
((AnimationDrawable) exploredImageView.getDrawable()).stop();
((AnimationDrawable) exploredImageView.getDrawable()).start();
} else {
if (listener != null) {
listener.onCancel();
}
}
}

/**
* 获取当前位置和起点的中间点为贝塞尔曲线的操作点
*/

anchorX = (event.getRawX() - thisX + startX) / 2;
anchorY = (event.getRawY() - thisY + startY) / 2;
x = event.getRawX() - thisX;
y = event.getRawY() - thisY;

//在当前位置绘制展示view
tipImageView.setX(x - tipImageView.getWidth() / 2);
tipImageView.setY(y - tipImageView.getHeight() / 2);

invalidate();
return true;
}
});
}

5.返回展示View的接口 和 拖动监听事件接口

1
2
3
4
5
6
7
8
9
10
11
public interface Func<Tresult> {
Tresult invoke(); //返回类型为Tresult的对象实例
}

public static interface Listener {
void onStart();

void onComplete();

void onCancel();
}

  通过位置记录和绘制view,根据手势时间来计算和改变view的状态。其中有三个view,分别对应:

  • attachView 目标view,该view可被拖动(比如未读消息数view)
  • tipImageView 拖动时候显示的view,比如点击未读红点拖动和显示的却是蓝色气泡等(默认显示原目标view)
  • exploredImageView 移除时候负责显示带动画效果的view

    以上就是我们的TipsView简易包装。


3.综述总结

  如果单单只是在目标view的父view区域拖动该未读图标,大可不必在根布局里添加一个顶层view。但是如果我们想要实现目标view全屏任意位置拖动,就需要额外模拟一个填充View,隐藏掉原来的view,达到全屏幕拖动的效果。
  需要说明的一点,在有些时候,比如listview或者scrollView等布局下,我们会发现无法拖动目标view,这是因为这些父容器有拦截触摸滑动事件。所以我们必须在onStart()监听事件接口中设置

//当requestDisallowInterceptTouchEvent 参数为true的时候 它不会拦截其子控件的 触摸事件
requestDisallowInterceptTouchEvent(true);

实现目标view的触摸事件有效。

  注: 请在布局根视图添加TipView,并保持在最顶层。他将决定你的拖动范围。
  注: 请在布局根视图添加TipView,并保持在最顶层。他将决定你的拖动范围。

  最后,附上代码下载地址:
  示例demo源码下载地址 (资源上传较慢,如果不可用,试试这里