这两天学习了使用Path绘制贝塞尔曲线相关,然后自己动手做了一个类似QQ未读消息可拖拽的小气泡,效果图如下:
接下来一步一步的实现整个过程。
基本原理
其实就是使用Path绘制三点的二次方贝塞尔曲线来完成那个妖娆的曲线的。然后根据触摸点不断绘制对应的圆形,根据距离的改变改变原始固定圆形的半径大小。最后就是松手后返回或者爆裂的实现。
Path介绍:
顾名思义,就是一个路径的意思,Path里面有很多的方法,本次设计主要用到的相关方法有
moveTo()
移动Path到一个指定的点
quadTo()
绘制二次贝塞尔曲线,接收两个点,第一个是控制弧度的点,第二个是终点。
lineTo()
就是连线
close()
闭合Path路径,
reset()
重置Path的相关设置
- Path入门热身:
1
2
3
4
5
6
7
8
9
10
|
path
.
reset
(
)
;
path
.
moveTo
(
200
,
200
)
;
//第一个坐标是对应的控制的坐标,第二个坐标是终点坐标
path
.
quadTo
(
400
,
250
,
600
,
200
)
;
canvas
.
drawPath
(
path
,
paint
)
;
canvas
.
translate
(
0
,
200
)
;
//调用close,就会首尾闭合连接
path
.
close
(
)
;
canvas
.
drawPath
(
path
,
paint
)
;
|
记得不要在onDraw方法中new Path
或者 Paint
哟!
具体实现拆分:
其实整个过程就是绘制了两个贝塞尔二次曲线的的闭合Path路径,然后在上面添加两个圆形。
- 闭合的
Path
路径实现从左上点画二次贝塞尔曲线到左下点,左下点连线到右下点,右下点二次贝塞尔曲线到右上点,最后闭合一下!! - 相关坐标的确定
这是这次里面的难点之一,因为涉及到了数学里面的一个sin,cos,tan等等,我其实也忘完了,然后又脑补了一下,废话不多说,直接上图!!
为什么自己要亲自去画一下呢,因为画了你才知道,在360旋转的过程中,角标体系是有两套的,如果就使用一套来画的话,就画出现在旋转的过程中曲线重叠在一起的情况!
问题已经抛出来了,接下来直接看看代码实现!
角度确定
根据贴出来的原理图可以知道,我们可以使用起始圆心坐标和拖拽的圆心坐标,根据反正切函数来得到具体的弧度。
1
2
3
|
int
dy
=
Math
.
abs
(
CIRCLEY
-
startY
)
;
int
dx
=
Math
.
abs
(
CIRCLEX
-
startX
)
;
angle
=
Math
.
atan
(
dy
*
1.0
/
dx
)
;
|
ok,这里的startX,Y就是移动过程中的坐标。angle就是得到的对应的弧度(角度)。
相关Path绘制
前面已经提到在旋转的过程中有两套坐标体系,一开始我也很纠结这个坐标体系要怎么确定,后面又恍然大悟,其实相当于就是一三象限正比例增长,二四象限,反比例增长。
1
2
|
flag
=
(
startY
-
CIRCLEY
)
*
(
startX
-
CIRCLEX
)
<=
0
;
//增加一个flag,用于判断使用哪种坐标体系。
|
最最重要的来了,绘制相关的Path路径!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
path
.
reset
(
)
;
if
(
flag
)
{
//第一个点
path
.
moveTo
(
(
float
)
(
CIRCLEX
-
Math
.
sin
(
angle
)
*
ORIGIN_RADIO
)
,
(
float
)
(
CIRCLEY
-
Math
.
cos
(
angle
)
*
ORIGIN_RADIO
)
)
;
path
.
quadTo
(
(
float
)
(
(
startX
+
CIRCLEX
)
*
0.5
)
,
(
float
)
(
(
startY
+
CIRCLEY
)
*
0.5
)
,
(
float
)
(
startX
-
Math
.
sin
(
angle
)
*
DRAG_RADIO
)
,
(
float
)
(
startY
-
Math
.
cos
(
angle
)
*
DRAG_RADIO
)
)
;
path
.
lineTo
(
(
float
)
(
startX
+
Math
.
sin
(
angle
)
*
DRAG_RADIO
)
,
(
float
)
(
startY
+
Math
.
cos
(
angle
)
*
DRAG_RADIO
)
)
;
path
.
quadTo
(
(
float
)
(
(
startX
+
CIRCLEX
)
*
0.5
)
,
(
float
)
(
(
startY
+
CIRCLEY
)
*
0.5
)
,
(
float
)
(
CIRCLEX
+
Math
.
sin
(
angle
)
*
ORIGIN_RADIO
)
,
(
float
)
(
CIRCLEY
+
Math
.
cos
(
angle
)
*
ORIGIN_RADIO
)
)
;
path
.
close
(
)
;
canvas
.
drawPath
(
path
,
paint
)
;
}
else
{
//第一个点
path
.
moveTo
(
(
float
)
(
CIRCLEX
-
Math
.
sin
(
angle
)
*
ORIGIN_RADIO
)
,
(
float
)
(
CIRCLEY
+
Math
.
cos
(
angle
)
*
ORIGIN_RADIO
)
)
;
path
.
quadTo
(
(
float
)
(
(
startX
+
CIRCLEX
)
*
0.5
)
,
(
float
)
(
(
startY
+
CIRCLEY
)
*
0.5
)
,
(
float
)
(
startX
-
Math
.
sin
(
angle
)
*
DRAG_RADIO
)
,
(
float
)
(
startY
+
Math
.
cos
(
angle
)
*
DRAG_RADIO
)
)
;
path
.
lineTo
(
(
float
)
(
startX
+
Math
.
sin
(
angle
)
*
DRAG_RADIO
)
,
(
float
)
(
startY
-
Math
.
cos
(
angle
)
*
DRAG_RADIO
)
)
;
path
.
quadTo
(
(
float
)
(
(
startX
+
CIRCLEX
)
*
0.5
)
,
(
float
)
(
(
startY
+
CIRCLEY
)
*
0.5
)
,
(
float
)
(
CIRCLEX
+
Math
.
sin
(
angle
)
*
ORIGIN_RADIO
)
,
(
float
)
(
CIRCLEY
-
Math
.
cos
(
angle
)
*
ORIGIN_RADIO
)
)
;
path
.
close
(
)
;
canvas
.
drawPath
(
path
,
paint
)
;
}
|
这里的代码就是把图片上相关的数学公式Java化而已!
到这里,其实主要的工作就完成的差不多了!
接下来,设置paint
为填充的效果,最后再画两个圆
1
2
3
|
paint
.
setStyle
(
Paint
.
Style
.
FILL
)
canvas
.
drawCircle
(
CIRCLEX
,
CIRCLEY
,
ORIGIN_RADIO
,
paint
)
;
//默认的
canvas
.
drawCircle
(
startX
==
0
?
CIRCLEX
:
startX
,
startY
==
0
?
CIRCLEY
:
startY
,
DRAG_RADIO
,
paint
)
;
//拖拽的
|
就可以绘制出想要的效果了!
这里不得不再说说onTouch
的处理!
1
2
3
4
5
6
7
|
case
MotionEvent
.
ACTION_DOWN
:
//有事件先拦截再说!!
getParent
(
)
.
requestDisallowInterceptTouchEvent
(
true
)
;
CurrentState
=
STATE_IDLE
;
animSetXY
.
cancel
(
)
;
startX
=
(
int
)
ev
.
getX
(
)
;
startY
=
(
int
)
ev
.
getRawY
(
)
;
break
;
|
处理一下事件分发的坑!
测量和布局
这样基本过得去了,但是我们的布局什么的还没有处理,math_parent是万万没法使用到具体项目当中去的!
测量的时候,如果发现不是精准模式,那么都手动去计算出需要的宽度和高度。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Override
protected
void
onMeasure
(
int
widthMeasureSpec
,
int
heightMeasureSpec
)
{
int
modeWidth
=
MeasureSpec
.
getMode
(
widthMeasureSpec
)
;
int
modeHeight
=
MeasureSpec
.
getMode
(
heightMeasureSpec
)
;
if
(
modeWidth
==
MeasureSpec
.
UNSPECIFIED
||
modeWidth
==
MeasureSpec
.
AT_MOST
)
{
widthMeasureSpec
=
MeasureSpec
.
makeMeasureSpec
(
DEFAULT_RADIO
*
2
,
MeasureSpec
.
EXACTLY
)
;
}
if
(
modeHeight
==
MeasureSpec
.
UNSPECIFIED
||
modeHeight
==
MeasureSpec
.
AT_MOST
)
{
heightMeasureSpec
=
MeasureSpec
.
makeMeasureSpec
(
DEFAULT_RADIO
*
2
,
MeasureSpec
.
EXACTLY
)
;
}
super
.
onMeasure
(
widthMeasureSpec
,
heightMeasureSpec
)
;
}
|
然后在布局变化时,获取相关坐标,确定初始圆心坐标:
1
2
3
4
5
6
|
@Override
protected
void
onSizeChanged
(
int
w
,
int
h
,
int
oldw
,
int
oldh
)
{
super
.
onSizeChanged
(
w
,
h
,
oldw
,
oldh
)
;
CIRCLEX
=
(
int
)
(
(
w
)
*
0.5
+
0.5
)
;
CIRCLEY
=
(
int
)
(
(
h
)
*
0.5
+
0.5
)
;
}
|
1
2
3
4
5
|
<
com
.
lovejjfg
.
circle
.
DragBubbleView
android
:
id
=
“@+id/dbv”
android
:
layout_width
=
“wrap_content”
android
:
layout_height
=
“wrap_content”
android
:
layout_gravity
=
“center”
/
>
|
这样之后,又会出现一个问题,那就是wrap_content
之后,这个View能绘制的区域只有自身那么大了,拖拽了都看不见了!这个坑怎么办呢,其实很简单,父布局加上android:clipChildren="false"
的属性!
这个坑也算是解决了!!
相关状态的确定
我们是不希望它可以无限的拖拽的,就是有一个拖拽的最远距离,还有就是放手后的返回,爆裂。那么对应的,这里需要确定几种状态:
1
2
3
4
5
6
7
8
9
10
|
private
final
static
int
STATE_IDLE
=
1
;
//静止的状态
private
final
static
int
STATE_DRAG_NORMAL
=
2
;
//正在拖拽的状态
private
final
static
int
STATE_DRAG_BREAK
=
3
;
//断裂后的拖拽状态
private
final
static
int
STATE_UP_BREAK
=
4
;
//放手后的爆裂的状态
private
final
static
int
STATE_UP_BACK
=
5
;
//放手后的没有断裂的返回的状态
private
final
static
int
STATE_UP_DRAG_BREAK_BACK
=
6
;
//拖拽断裂又返回的状态
private
int
CurrentState
=
STATE_IDLE
;
private
int
MIN_RADIO
=
(
int
)
(
ORIGIN_RADIO
*
0.4
)
;
//最小半径
private
int
MAXDISTANCE
=
(
int
)
(
MIN_RADIO
*
13
)
;
//最远的拖拽距离
|
确定好这些之后,在move的时候,就要去做相关判断了:
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
|
case
MotionEvent
.
ACTION_MOVE
:
//移动的时候
startX
=
(
int
)
ev
.
getX
(
)
;
startY
=
(
int
)
ev
.
getY
(
)
;
updatePath
(
)
;
invalidate
(
)
;
break
;
private
void
updatePath
(
)
{
int
dy
=
Math
.
abs
(
CIRCLEY
-
startY
)
;
int
dx
=
Math
.
abs
(
CIRCLEX
-
startX
)
;
double
dis
=
Math
.
sqrt
(
dy
*
dy
+
dx
*
dx
)
;
if
(
dis
<=
MAXDISTANCE
)
{
//增加的情况,原始半径减小
if
(
CurrentState
==
STATE_DRAG_BREAK
||
CurrentState
==
STATE_UP_DRAG_BREAK_BACK
)
{
CurrentState
=
STATE_UP_DRAG_BREAK_BACK
;
}
else
{
CurrentState
=
STATE_DRAG_NORMAL
;
}
ORIGIN_RADIO
=
(
int
)
(
DEFAULT_RADIO
-
(
dis
/
MAXDISTANCE
)
*
(
DEFAULT_RADIO
-
MIN_RADIO
)
)
;
Log
.
e
(
TAG
,
“distance: “
+
(
int
)
(
(
1
-
dis
/
MAXDISTANCE
)
*
MIN_RADIO
)
)
;
Log
.
i
(
TAG
,
“distance: “
+
ORIGIN_RADIO
)
;
}
else
{
CurrentState
=
STATE_DRAG_BREAK
;
}
// distance = dis;
flag
=
(
startY
-
CIRCLEY
)
*
(
startX
-
CIRCLEX
)
<=
0
;
Log
.
i
(
“TAG”
,
“updatePath: “
+
flag
)
;
angle
=
Math
.
atan
(
dy
*
1.0
/
dx
)
;
}
|
updatePath()
的方法之前已经看过部分了,这次的就是完整的。
这里做的事就是根据拖拽的距离更改相关的状态,并根据百分比来修改原始圆形的半径大小。还有就是之前介绍的确定相关的弧度!
最后放手的时候:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
case
MotionEvent
.
ACTION_UP
:
if
(
CurrentState
==
STATE_DRAG_NORMAL
)
{
CurrentState
=
STATE_UP_BACK
;
valueX
.
setIntValues
(
startX
,
CIRCLEX
)
;
valueY
.
setIntValues
(
startY
,
CIRCLEY
)
;
animSetXY
.
start
(
)
;
}
else
if
(
CurrentState
==
STATE_DRAG_BREAK
)
{
CurrentState
=
STATE_UP_BREAK
;
invalidate
(
)
;
}
else
{
CurrentState
=
STATE_UP_DRAG_BREAK_BACK
;
valueX
.
setIntValues
(
startX
,
CIRCLEX
)
;
valueY
.
setIntValues
(
startY
,
CIRCLEY
)
;
animSetXY
.
start
(
)
;
}
break
;
|
自动返回这里使用到的 ValueAnimator
,
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
|
animSetXY
=
new
AnimatorSet
(
)
;
valueX
=
ValueAnimator
.
ofInt
(
startX
,
CIRCLEX
)
;
valueY
=
ValueAnimator
.
ofInt
(
startY
,
CIRCLEY
)
;
animSetXY
.
playTogether
(
valueX
,
valueY
)
;
valueX
.
setDuration
(
500
)
;
valueY
.
setDuration
(
500
)
;
valueX
.
setInterpolator
(
new
OvershootInterpolator
(
)
)
;
valueY
.
setInterpolator
(
new
OvershootInterpolator
(
)
)
;
valueX
.
addUpdateListener
(
new
ValueAnimator
.
AnimatorUpdateListener
(
)
{
@Override
public
void
onAnimationUpdate
(
ValueAnimator
animation
)
{
startX
=
(
int
)
animation
.
getAnimatedValue
(
)
;
Log
.
e
(
TAG
,
“onAnimationUpdate-startX: “
+
startX
)
;
invalidate
(
)
;
}
}
)
;
valueY
.
addUpdateListener
(
new
ValueAnimator
.
AnimatorUpdateListener
(
)
{
@Override
public
void
onAnimationUpdate
(
ValueAnimator
animation
)
{
startY
=
(
int
)
animation
.
getAnimatedValue
(
)
;
Log
.
e
(
TAG
,
“onAnimationUpdate-startY: “
+
startY
)
;
invalidate
(
)
;
}
}
)
;
|
最后在看看完整的onDraw
方法吧!
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
|
@Override
protected
void
onDraw
(
Canvas
canvas
)
{
switch
(
CurrentState
)
{
case
STATE_IDLE
:
//空闲状态,就画默认的圆
if
(
showCircle
)
{
canvas
.
drawCircle
(
CIRCLEX
,
CIRCLEY
,
ORIGIN_RADIO
,
paint
)
;
//默认的
}
break
;
case
STATE_UP_BACK
:
//执行返回的动画
case
STATE_DRAG_NORMAL
:
//拖拽状态 画贝塞尔曲线和两个圆
path
.
reset
(
)
;
if
(
flag
)
{
//第一个点
path
.
moveTo
(
(
float
)
(
CIRCLEX
-
Math
.
sin
(
angle
)
*
ORIGIN_RADIO
)
,
(
float
)
(
CIRCLEY
-
Math
.
cos
(
angle
)
*
ORIGIN_RADIO
)
)
;
path
.
quadTo
(
(
float
)
(
(
startX
+
CIRCLEX
)
*
0.5
)
,
(
float
)
(
(
startY
+
CIRCLEY
)
*
0.5
)
,
(
float
)
(
startX
-
Math
.
sin
(
angle
)
*
DRAG_RADIO
)
,
(
float
)
(
startY
-
Math
.
cos
(
angle
)
*
DRAG_RADIO
)
)
;
path
.
lineTo
(
(
float
)
(
startX
+
Math
.
sin
(
angle
)
*
DRAG_RADIO
)
,
(
float
)
(
startY
+
Math
.
cos
(
angle
)
*
DRAG_RADIO
)
)
;
path
.
quadTo
(
(
float
)
(
(
startX
+
CIRCLEX
)
*
0.5
)
,
(
float
)
(
(
startY
+
CIRCLEY
)
*
0.5
)
,
(
float
)
(
CIRCLEX
+
Math
.
sin
(
angle
)
*
ORIGIN_RADIO
)
,
(
float
)
(
CIRCLEY
+
Math
.
cos
(
angle
)
*
ORIGIN_RADIO
)
)
;
path
.
close
(
)
;
canvas
.
drawPath
(
path
,
paint
)
;
}
else
{
//第一个点
path
.
moveTo
(
(
float
)
(
CIRCLEX
-
Math
.
sin
(
angle
)
*
ORIGIN_RADIO
)
,
(
float
)
(
CIRCLEY
+
Math
.
cos
(
angle
)
*
ORIGIN_RADIO
)
)
;
path
.
quadTo
(
(
float
)
(
(
startX
+
CIRCLEX
)
*
0.5
)
,
(
float
)
(
(
startY
+
CIRCLEY
)
*
0.5
)
,
(
float
)
(
startX
-
Math
.
sin
(
angle
)
*
DRAG_RADIO
)
,
(
float
)
(
startY
+
Math
.
cos
(
angle
)
*
DRAG_RADIO
)
)
;
path
.
lineTo
(
(
float
)
(
startX
+
Math
.
sin
(
angle
)
*
DRAG_RADIO
)
,
(
float
)
(
startY
-
Math
.
cos
(
angle
)
*
DRAG_RADIO
)
)
;
path
.
quadTo
(
(
float
)
(
(
startX
+
CIRCLEX
)
*
0.5
)
,
(
float
)
(
(
startY
+
CIRCLEY
)
*
0.5
)
,
(
float
)
(
CIRCLEX
+
Math
.
sin
(
angle
)
*
ORIGIN_RADIO
)
,
(
float
)
(
CIRCLEY
-
Math
.
cos
(
angle
)
*
ORIGIN_RADIO
)
)
;
path
.
close
(
)
;
canvas
.
drawPath
(
path
,
paint
)
;
}
if
(
showCircle
)
{
canvas
.
drawCircle
(
CIRCLEX
,
CIRCLEY
,
ORIGIN_RADIO
,
paint
)
;
//默认的
canvas
.
drawCircle
(
startX
==
0
?
CIRCLEX
:
startX
,
startY
==
0
?
CIRCLEY
:
startY
,
DRAG_RADIO
,
paint
)
;
//拖拽的
}
break
;
case
STATE_DRAG_BREAK
:
//拖拽到了上限,画拖拽的圆:
case
STATE_UP_DRAG_BREAK_BACK
:
if
(
showCircle
)
{
canvas
.
drawCircle
(
startX
==
0
?
CIRCLEX
:
startX
,
startY
==
0
?
CIRCLEY
:
startY
,
DRAG_RADIO
,
paint
)
;
//拖拽的
}
break
;
case
STATE_UP_BREAK
:
//画出爆裂的效果
canvas
.
drawCircle
(
startX
-
25
,
startY
-
25
,
10
,
circlePaint
)
;
canvas
.
drawCircle
(
startX
+
25
,
startY
+
25
,
10
,
circlePaint
)
;
canvas
.
drawCircle
(
startX
,
startY
-
25
,
10
,
circlePaint
)
;
canvas
.
drawCircle
(
startX
,
startY
,
18
,
circlePaint
)
;
canvas
.
drawCircle
(
startX
-
25
,
startY
,
10
,
circlePaint
)
;
break
;
}
}
|
到这里,成品就出来了!!
总结:
1、确定默认圆形的坐标;
2、根据move的情况,实时获取最新的坐标,根据移动的距离(确定出角度),更新相关的状态,画出相关的Path路径。超出上限,不再画Path路径。
3、松手时,根据相关的状态,要么带Path路径执行动画返回,要么不带Path路径直接返回,要么直接爆裂!
相关源码请移步Github,喜欢就请Start或者 fork一下吧,有问题欢迎留言或者issue。。