Hymanme

使用贝塞尔曲线实现“一键下班”功能

前言

什么是“一键下班”?

QQbubble

就是这个 QQ 消息气泡清除效果, QQ 气泡拖拽会产生粘性效果,拖拽到一定长度之后,连线断裂,气泡随之消失。大家都知道这用到了贝塞尔曲线,用来画链接线部分,ok,来学习一下贝塞尔曲线吧。

贝塞尔曲线

如果想用计算机来画一条直线,很简单,只要确定两个点,起点和终点就可以了,但是如果想要让计算机画一条曲线,那计算机该如何做呢?咳咳,肯定是通过数学公式来呗,
1962年,法国数学家 Pierre Bézier 第一个研究了这种矢量绘制曲线的方法,并给出了详细的计算公式,因此按照这样的公式绘制出来的曲线就用他的姓氏来命名的,叫做贝塞尔曲线。那么可以说贝塞尔曲线就是通过某种公式绘制出的一条光滑曲线

一介贝塞尔曲线(直线)

方程:
bezier1

曲线:
bezier1_line

一介贝塞尔曲线就是一条直线,确定两个点,得到一条直线。Android 中对应的方法为lineTo(float x, float y)
x:终点 x 坐标,y:终点 y 坐标

二介贝塞尔曲线

方程:
bezier2

曲线:
bezier2_line

二介贝塞尔曲线需要一对起点和终点以及一个控制点,如图控制点 p1 控制着曲线的拉伸程度。对应于 Android 中方法quadTo(float x1, float y1, float x2, float y2)
x1,y1:控制点 x,y 坐标,x2,y2:终点 x,y 坐标。

三介贝塞尔曲线

方程:
bezier3

曲线:
bezier3_line

三介贝塞尔曲线需要一对起点和终点以及两个额外的控制点,控制点 p1,p2 控制着曲线的弯曲程度以及弯曲方向。对应于 Android 中方法cubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
x1,y1:控制点1 x,y 坐标,x2,y2:控制点2 x,y 坐标,x3,y3:终点 x,y 坐标。

任意介贝塞尔曲线

方程:
bezierx

高阶实际用的不多,可以通过多次低阶来实现。

“一键下班”设计

需求—分析—实现
详见QQ手机版 5.0“一键下班”设计小结

计算连接线算法

算法参考了一篇博客,原文网址找不到了,找了一个内容一样的博客,应该也是原作者博客,在此先贴出博客地址,根据博客介绍,总结出几个重要控制点的计算公式,如下图(凑合着看,呵呵)
source

no diao use

其中两条拉伸的曲线 p12、p34,和两个圆,分别是跟随手指一动的圆或椭圆(显示消息数目的 view),和原始位置处的随着拖拽距离加大圆半径越来越小的圆。

实现思路

  1. 开始显示消息数目的 view,可以是一个带背景的 TextView
  2. 手指触摸该 view 时记录状态
  3. 手指拖着 view 滑动时,计算触摸点和原始点距离,是否大于临界距离
    • 大于最大值:断裂链接曲线,并停止绘制
    • 不大于:计算2对顶点以及对应的控制点坐标
  4. 使用计算好的坐标等,重绘 view,包括曲线和原始点的圆,其中原始点的圆半径根据拖拽距离进行动态计算
  5. 重复3,4步

代码

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
@Override
protected void onDraw(Canvas canvas) {
//计算曲线路径,可能距离超过,曲线已经断裂
calculatePath();
if (isTouch && !isBroken) {
//绘制曲线path
canvas.drawPath(mPath, mPaint);
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.OVERLAY);
canvas.drawCircle(mStartX, mStartY, mDotRadius, mPaint);
//触摸点的圆 canvas.drawCircle(mTextView.getX() + mTextView.getWidth() / 2, mTextView.getY() + mTextView.getHeight() / 2, mDefaultDotRadius, mPaint);
} else {
//曲线断裂,清楚曲线
canvas.drawCircle(mStartX, mStartY, 0, mPaint);
canvas.drawCircle(mTouchX, mTouchY, 0, mPaint);
canvas.drawLine(0, 0, 0, 0, mPaint);
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.OVERLAY);
}
super.onDraw(canvas);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mTouchX = (int) event.getX();
mTouchY = (int) event.getY();
Log.d("mTouchX=", mTouchX + "");
Log.d("mTouchY=", mTouchY + "");
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
isBroken = false;
mDotRadius = mDefaultDotRadius;
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
//清除消息动画或者返回远处反弹动画
isTouch = false;
if (isBroken) {
disappearAnim();
} else {
returnBackAnim(mTextView.getX(), mTextView.getY());
}
break;
case MotionEvent.ACTION_MOVE:
if (Math.abs(mTouchX - mStartX) < TOUCH_SLOP && Math.abs(mTouchY - mStartY) < TOUCH_SLOP) {
isTouch = false;
} else {
mTextView.setX(mTouchX - mTextView.getWidth() / 2);
mTextView.setY(mTouchY - mTextView.getHeight() / 2);
isTouch = true;
}
break;
}
invalidateView();
return true;
}
//重绘视图
public void invalidateView() {
if (Looper.myLooper() == Looper.getMainLooper()) {
invalidate();
} else {
postInvalidate();
}
}
//计算绘制区域的 path 路径
//每次手指按住气泡在屏幕上滑动都计算一下 path,来确定状态
private void calculatePath() {
//气泡拖拽的距离
int dragLen = (int) Math.sqrt(Math.pow(mTouchX - mStartX, 2) + Math.pow(mStartY - mTouchY, 2));
if (dragLen > mMaxDragLen) {
//如果大于最大拖拽距离,则改变状态为 isBroken:断裂
isBroken = true;
} else {
//未达到最大断裂距离,计算path: 四个顶点p1,p2,p3,p4 => 控制点 anchor1,anchor2
//初始位置点圆半径,根据气泡拖拽偏离距离改变原始气泡的大小,即越网远处滑动,初始的圆就越小
mDotRadius = Math.max(mDefaultDotRadius * (1.0f - dragLen / mMaxDragLen), 12);
//伽马角和阿尔法角度
float r = (float) Math.asin((mDefaultDotRadius - mDotRadius) / dragLen);
float a = (float) Math.atan((mStartY - mTouchY) / (mTouchX - mStartX));
//几个偏移量,中间数据用来确定4个顶点坐标
float offset1X = (float) Math.cos(Math.PI / 2 - r - a);
float offset1Y = (float) Math.sin(Math.PI / 2 - r - a);
float offset2X = (float) Math.cos(Math.PI / 2 + r - a);
float offset2Y = (float) Math.sin(Math.PI / 2 + r - a);
//得到绘制贝塞尔曲线需要的四个点,p1,p2,p3,p4
//第一条曲线:p1~~~p2
float x1 = mStartX - offset1X * mDotRadius;
float y1 = mStartY - offset1Y * mDotRadius;
float x2 = mTouchX - offset1X * mDefaultDotRadius;
float y2 = mTouchY - offset1Y * mDefaultDotRadius;
//第二条曲线:p3~~~p4
float x3 = mStartX + offset2X * mDotRadius;
float y3 = mStartY + offset2Y * mDotRadius;
float x4 = mTouchX + offset2X * mDefaultDotRadius;
float y4 = mTouchY + offset2Y * mDefaultDotRadius;
//两个控制点,anchor1:p1,p4点的重心,anchor2:p2,p3点的重心,
float mAnchor1X = (x1 + x4) / 2;
float mAnchor1Y = (y1 + y4) / 2;
float mAnchor2X = (x2 + x3) / 2;
float mAnchor2Y = (y2 + y3) / 2;
//重置路径
mPath.reset();
//路径移动到起点,p1
mPath.moveTo(x1, y1);
//画曲线p1~~~p2,二介贝塞尔,参数一个控制点,一个重点
mPath.quadTo(mAnchor1X, mAnchor1Y, x2, y2);
//此时路径已经到p2点了,画直线p2---p4
mPath.lineTo(x4, y4);
//画曲线p4~~~p3
mPath.quadTo(mAnchor2X, mAnchor2Y, x3, y3);
//封闭曲线,画直线p3---p1,到此画了一个沙漏的形状,接下来只要画两头的圆就可以了
mPath.lineTo(x1, y1);
}
}

效果图:
example

总结

动画可以给交互带来很大的好处,提高用户体验,之前在使用 QQ 气泡功能时候也感到此功能很神奇,查阅一些资料后发现,实现出来也不是很难,主要是点坐标的计算问题,这也牵扯到数学知识,还好有学霸提供了公式,方便了功能的实现。通过这个实例,也不难发现学好数学是很有必要的。最后贴上项目地址BezierAnimation