先放一个效果,这是比平常气泡尺寸大很多的效果了。
首先分析,这个view怎么分解成我们经常做的效果:
- 静部分:两个圆,两条弧线
- 圆就是两个完整的circle,这个好说
- 弧线,我们发现,不是标准的椭圆弧了,这里一般使用二阶贝塞尔曲线(
网上有很多讲的非常棒的文章,简单粗暴的说,就是PS里面,用两个锚点画曲线)
图:
贝塞尔曲线的绘制过程:
A —— 二阶曲线(quadTo)——B——一阶曲线(就是lineto)——C——二阶曲线(quadTo)——D——闭合
这里quadTo()里的前两个参数是控制点坐标,也就是图中的g点,后两个参数是结束点,图中B/D既是。
- 动部分:弧线和一个圆的形状是变化的,这部分可以监听touch来刷新;
回弹效果,自定义动画实现,
最后气泡消失的爆炸效果,一般是使用帧动画,本例暂时没有写这个
好,思路基本捋清楚了。
创建一个View的子类
定义可以外部控制的变量
老规矩,attrs文件中:
java代码中:
先定义各种需要用到的变量和准确的注释
/** 气泡颜色*/
private int mColor;
/** 中心文字颜色 */
private int mTextColor;
/** 中心文字内容 */
private String mText;
/** 原始气泡大小 */
private float mBubbleRadius;
/** 拖拽时跟随手指的气泡大小 */
private float mDragBubbleRadius;
/** 原始气泡的x、y坐标 */
private float mBCx, mBCy;
/** 拖拽气泡的x、y坐标 */
private float mDBCx, mDBCy;
/** 两圆圆心的距离 */
private float defLength;
/** 两圆圆心距离阀值,超过此值原始气泡消失 */
private float defMaxLength;
/*以下是贝塞尔曲线的相关点*/
/** 原始气泡圆的起点x y */
private float mStartX, mStartY;
/** 原始气泡圆的终点 x y */
private float mEndX, mEndY;
/** 拖拽中的圆的起点 x y */
private float mDStartX, mDStartY;
/** 拖拽中的圆的终点 */
private float mDEndX, mDEndY;
/** 控制点的x y坐标 */
private float mCtrlX, mCtrlY;
/** 文字的尺寸 */
private Rect mRect;
/** 气泡的状态enum类 */
enum BubbleState {
/** 默认,无法拖拽 */ DEFAULT,
/** 拖拽 */ DRAG,
/** 移动 */ MOVE,
/** 消失 */ DISMISS
}
/** 气泡的状态 */
private BubbleState mState;
private Paint mPaint;
private Paint mTextPaint;//文字画笔
private Path mBezierPath;//曲线的画笔
构造器
public MsgBubbleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray typedArray = context.getTheme().obtainStyledAttributes(
attrs, R.styleable.MsgBubbleView, 0, defStyleAttr);
for (int i = 0; i < typedArray.length(); i++) {
int attr = typedArray.getIndex(i);
switch (attr) {
case R.styleable.MsgBubbleView_centerText:
mText = typedArray.getString(attr);
break;
case R.styleable.MsgBubbleView_textColor:
mTextColor = typedArray.getColor(attr, Color.WHITE);
break;
case R.styleable.MsgBubbleView_color:
mColor = typedArray.getColor(attr, Color.RED);
break;
case R.styleable.MsgBubbleView_bubbleRadius:
mBubbleRadius = typedArray.getDimensionPixelSize(attr,
(int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 20, getResources().getDisplayMetrics()));
break;
}
}
mDragBubbleRadius = mBubbleRadius;
defMaxLength = mBubbleRadius * 8;//两圆心距离最大阀值
typedArray.recycle();
mState = DEFAULT;
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setColor(mColor);
mPaint.setTextSize(2);
mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setColor(mTextColor);
mTextPaint.setTextSize(16);
mBezierPath = new Path();
mRect = new Rect();
}
中间测量什么的可以先选择省略,直接进入onDraw():
//画小圆
canvas.drawCircle(mBCx, mBCy, mBubbleRadius, mPaint);
//控制点在两圆心中点,起点终点位于圆上固定位置————圆上与两圆心连线垂直的过圆心的直线两端
mCtrlX = (mBCx + mDBCx) / 2;
mCtrlY = (mBCy + mDBCy) / 2;
float tx = (mDBCy - mBCy) / defLength;
float ty = (mDBCx - mBCx) / defLength;
mStartX = mBCx - mBubbleRadius * tx;
mStartY = mBCy + mBubbleRadius * ty;
mDEndX = mDBCx - mDragBubbleRadius * tx;
mDEndY = mDBCy + mDragBubbleRadius * ty;
mDStartX = mDBCx + mDragBubbleRadius * tx;
mDStartY = mDBCy - mDragBubbleRadius * ty;
mEndX = mBCx + mBubbleRadius * tx;
mEndY = mBCy - mBubbleRadius * ty;
//画贝塞尔曲线
mBezierPath.reset();
mBezierPath.moveTo(mStartX, mStartY);
mBezierPath.quadTo(mCtrlX, mCtrlY, mDEndX, mDEndY);
mBezierPath.lineTo(mDStartX, mDStartY);
mBezierPath.quadTo(mCtrlX, mCtrlY, mEndX, mEndY);
mBezierPath.close();
canvas.drawPath(mBezierPath, mPaint);
这里使用的是我看过的博客中最多的一种写法,将贝塞尔曲线的起点和终点固定在圆上,圆上与两圆心连线垂直的直径的两端点。。这里的原理很简单,纸上画一下就能得到几个关键点了。
这时候的效果如下图(当然自己写的话还要写上后面的状态判断和触摸监听什么的,我这里是先把这种效果放出来),那么和qq上效果一致了,但是放大一点看,就是下面的效果:弧线和圆这里衔接明显很不圆润。(qq效果图我没仔细放大看,但是看着也有点像是这种效果)
那么怎么画没有断层感呢?
有一个方法,就是过控制点作两个圆的切线了(声明:其实两种方法的效果小图看不出来太大的区别,不是强迫症可以不用看我要实现的这种方法,算起来真的头疼)。
还是看这一个图:
g点是我们的控制点,过g点做两个圆的切线,这四条切线上的五个点就是两个贝塞尔曲线的关键点了。
当然,这个图当时设计的是g是两个圆心连线的中点,写到后面会出现一个bug,就是当两圆圆心距离小雨圆n的半径时,g点在圆n内,没有圆n的切线。所以本例后来就把g点的位置改为了两圆心相连的线段与两圆的交点的中点
,有点绕,但是其实画画图就明白了。
这里的控制点(即中点)需要一点计算了,还是先画一个模型图,将问题转化为一个简单的数学题:
思路如下:
线段 O1O2 = √ ((a- m )² + (b - n)²) = L
——————①
两个点之间长度 QK = L - R - r = L1
——————②
所以点J分割线段 L 的比例为 (L1/2+r):(L1/2+R)
然后引入比例公式就把点J求出来了,我把这一步封装成了一个方法:
/**
* 求两圆之间最短线段的中点
* @param r_1st 第一个圆的半径
* @param x_1st 第一个圆的x
* @param y_1st 第一个圆的y
* @param r_2nd 第二个圆半径
* @param x_2nd 第二个圆的x
* @param y_2nd 第二个圆的y
* @return 中点坐标
*/
public static PointF getCtrlPoint(float r_1st , float x_1st , float y_1st, float r_2nd , float x_2nd , float y_2nd){
PointF pointF = new PointF();
float l = (float) ((Math.sqrt( Math.pow(x_1st - x_2nd , 2) + Math.pow(y_1st - y_2nd ,2) ) - r_2nd - r_1st) /2);
float w = (l + r_1st) / (l + r_2nd);//比例
//由以下公式直接拿到中点坐标:
/*在直角坐标系内,已知两点P1(x1,y1),P2(x2,y2);在两点连线上有一点P,
设它的坐标为(x,y),且线段P1P比线段PP2的比值为 λ ,那么可以求出P的坐标为
x=(x1 + λ · x2) / (1 + λ)
y=(y1 + λ · y2) / (1 + λ)*/
pointF.x = (x_1st + w * x_2nd) / (1 + w);
pointF.y = (y_1st + w * y_2nd) / (1 + w);
return pointF;
}
在代码中使用:
//控制点
PointF ctrl = CircleUtils.getCtrlPoint(mBubbleRadius,mBCx,mBCy,mDragBubbleRadius,mDBCx,mDBCy);
mCtrlX = ctrl.x;
mCtrlY = ctrl.y;
好了,现在我们有了控制点,有了两个圆的半径和圆心坐标,接下来就是求出四个切点了。还是转化为数学题:
(图中坐标系可以去掉,这里坐标原点是圆心其实有点误导。)
思路很简单,五个常量,求切点坐标,我们很容易就能得到一个方程组,
(x-a)² + (y-b)² = r² ——————圆的方程
(n-y) / (m-x) *(y-b) / (x-a) = -1 ——————切线和与它垂直的直径斜率之积 = -1
简单吧,接下来就是愉快的化简了。
呵呵哒,我这里只说一点,后来我找了三个淘宝代做数学题的,都退款给我了。
最后的最后,找我的同事求救了,问她能不能用py解一下二元二次方程组,最后她推荐了我用matlab,挺好用:
解出来是这样的.
x =
- (- a^2 + m*a - b^2 + n*b + r^2)/(a - m) - ((b - n)*(a^2*b + b*m^2 + b*n^2 - 2*b^2*n - b*r^2 + n*r^2 + b^3 - 2*a*b*m + a*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2) - m*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2)))/((a - m)*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2))
- (- a^2 + m*a - b^2 + n*b + r^2)/(a - m) - ((b - n)*(a^2*b + b*m^2 + b*n^2 - 2*b^2*n - b*r^2 + n*r^2 + b^3 - 2*a*b*m - a*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2) + m*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2)))/((a - m)*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2))
y =
(a^2*b + b*m^2 + b*n^2 - 2*b^2*n - b*r^2 + n*r^2 + b^3 - 2*a*b*m + a*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2) - m*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2))/(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2)
(a^2*b + b*m^2 + b*n^2 - 2*b^2*n - b*r^2 + n*r^2 + b^3 - 2*a*b*m - a*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2) + m*r*(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2 - r^2)^(1/2))/(a^2 - 2*a*m + b^2 - 2*b*n + m^2 + n^2)
这个过程我们可以不看,根据算出来的解,封装成了一个方法:
/**
* 已知半径的圆的圆心和圆外一点坐标,求该点到圆的两个切点
*
* @param r 半径
* @param a 圆心x
* @param b 圆心y
* @param m 点x
* @param n 点y
* @return 两个切点
*/
public static float[] getPointTangency(float r, float a, float b, float m, float n) {
float[] points = new float[4];
float m_pow_2 = (float) Math.pow(m, 2);
float n_pow_2 = (float) Math.pow(n, 2);
float r_pow_2 = (float) Math.pow(r, 2);
float a_pow_2 = (float) Math.pow(a, 2);
float b_pow_2 = (float) Math.pow(b, 2);
float b_pow_3 = (float) Math.pow(b, 3);
float sq = (float) java.lang.Math.sqrt(a_pow_2 - 2 * m * a + b_pow_2 - 2 * n * b + m_pow_2 + n_pow_2 - r_pow_2);
float x1 = -(-a_pow_2 + m * a - b_pow_2 + n * b + r_pow_2) / (a - m)
- ((b - n) * (a_pow_2 * b + b * m_pow_2 + b * n_pow_2
- 2 * b_pow_2 * n - b * r_pow_2 + n * r_pow_2 + b_pow_3
- 2 * a * b * m + a * r * sq - m * r * sq))
/ ((a - m) * (a_pow_2 - 2 * a * m + b_pow_2 - 2 * b * n + m_pow_2 + n_pow_2));
float x2 = -(-a_pow_2 + m * a - b_pow_2 + n * b + r_pow_2) / (a - m)
- ((b - n) * (a_pow_2 * b + b * m_pow_2 + b * n_pow_2
- 2 * b_pow_2 * n - b * r_pow_2 + n * r_pow_2 + b_pow_3
- 2 * a * b * m - a * r * sq + m * r * sq))
/ ((a - m) * (a_pow_2 - 2 * a * m + b_pow_2 - 2 * b * n + m_pow_2 + n_pow_2));
float y1 = (a_pow_2 * b + b * m_pow_2 + b * n_pow_2 - 2 * b_pow_2 * n
- b * r_pow_2 + n * r_pow_2 + b_pow_3
- 2 * a * b * m + a * r * sq - m * r * sq) /
(a_pow_2 - 2 * a * m + b_pow_2 - 2 * b * n + m_pow_2 + n_pow_2);
float y2 = (a_pow_2 * b + b * m_pow_2 + b * n_pow_2 - 2 * b_pow_2 * n
- b * r_pow_2 + n * r_pow_2 + b_pow_3
- 2 * a * b * m - a * r * sq + m * r * sq) /
(a_pow_2 - 2 * a * m + b_pow_2 - 2 * b * n + m_pow_2 + n_pow_2);
// Log.d("MSL", "getPointTangency: " + x1 + "," + y1 + "\n" + x2 + "," + y2);
// Log.d("MSL", "getPointTangency: " + sq + ", " + a_pow_2 + "," + b_pow_2 + "," + m_pow_2 + "," + n_pow_2 + ",," + b_pow_3);
points[0] = x1;
points[2] = y1;
points[1] = x2;
points[3] = y2;
return points;
}
然后代码中使用是这样的:
float[] bubblePoints = CircleUtils.getPointTangency(mBubbleRadius, mBCx, mBCy, mCtrlX, mCtrlY);
float[] dragBubbllePoints = CircleUtils.getPointTangency(mDragBubbleRadius, mDBCx, mDBCy, mCtrlX, mCtrlY);
if ((mBCx < mDBCx && mBCy < mDBCy) || (mBCx > mDBCx && mBCy > mDBCy)){
//drag相对原位置于第一/三象限
mStartX = Math.min(bubblePoints[0],bubblePoints[1]);
mStartY = Math.max(bubblePoints[2],bubblePoints[3]);
mDEndX = Math.min(dragBubbllePoints[0],dragBubbllePoints[1]);
mDEndY = Math.max(dragBubbllePoints[2],dragBubbllePoints[3]);
mDStartX = Math.max(dragBubbllePoints[0],dragBubbllePoints[1]);
mDStartY = Math.min(dragBubbllePoints[2],dragBubbllePoints[3]);
mEndX = Math.max(bubblePoints[0],bubblePoints[1]);
mEndY = Math.min(bubblePoints[2],bubblePoints[3]);
}else if ((mBCx > mDBCx && mBCy < mDBCy) || (mBCx < mDBCx && mBCy > mDBCy)){
//drag相对原位置于第二/四象限
mStartX = Math.min(bubblePoints[0],bubblePoints[1]);
mStartY = Math.min(bubblePoints[2],bubblePoints[3]);
mDEndX = Math.min(dragBubbllePoints[0],dragBubbllePoints[1]);
mDEndY = Math.min(dragBubbllePoints[2],dragBubbllePoints[3]);
mDStartX = Math.max(dragBubbllePoints[0],dragBubbllePoints[1]);
mDStartY = Math.max(dragBubbllePoints[2],dragBubbllePoints[3]);
mEndX = Math.max(bubblePoints[0],bubblePoints[1]);
mEndY = Math.max(bubblePoints[2],bubblePoints[3]);
}
其实写到这里我就后悔了,优化了一点小功能,最后掉了不知道多少根头发!!
必须对比一下!
onDraw()里面就差不多了,绘制文字什么的就先不贴上来了。
重写onTouchEvent():
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (mState == DISMISS) break;
//request父控件不拦截点击事件
getParent().requestDisallowInterceptTouchEvent(true);
defLength = (float) Math.hypot(event.getX() - mDBCx, event.getY() - mDBCy);
if (defLength < mDragBubbleRadius + defMaxLength / 4) {
mState = DRAG;
} else {
mState = DEFAULT;
}
break;
case MotionEvent.ACTION_MOVE:
if (mState == DEFAULT) break;
getParent().requestDisallowInterceptTouchEvent(true);
mDBCx = event.getX();
mDBCy = event.getY();
defLength = (float) Math.hypot(mDBCx - mBCx, mDBCy - mBCy);
if (mState == DRAG) {
if (defLength < defMaxLength - defMaxLength / 4) {//
mBubbleRadius = mDragBubbleRadius - defLength / 8;//小球逐渐变小,直至逐渐消失
if (mBubbleStateListener != null) {
mBubbleStateListener.onDrag();
}
} else {//间距大于最大拖拽距离之后
mState = MOVE;//开始拖动
if (mBubbleStateListener != null) {
mBubbleStateListener.onMove();
}
}
}
invalidate();
break;
case MotionEvent.ACTION_UP:
getParent().requestDisallowInterceptTouchEvent(false);
//拖拽过程中未移动之前(小气泡没有消失之前)松手,气泡回到原来位置,并颤动一下
if (mState == DRAG) {
setBubbleResetAnimation();
} else if (mState == MOVE) {
if (defLength < 2 * mDragBubbleRadius) {
//松手的位置距离在气泡原始位置周围,说明user不想取消这个提示,让气泡回到原来位置并颤动
setBubbleResetAnimation();
} else {
//取消气泡显示,并显示dismiss的动画
}
}
break;
}
return true;
}
取消移动时的动画效果:
private void setBubbleResetAnimation() {
ValueAnimator valueAnimator = ValueAnimator.ofObject(new PointFEvaluator(),
new PointF(mDBCx, mDBCy), new PointF(mBCx, mBCy));
valueAnimator.setDuration(500);
valueAnimator.setInterpolator(new TimeInterpolator() {
@Override
public float getInterpolation(float input) {
float f = 0.4234219f;
return (float) (Math.pow(2, -4 * input) * Math.sin((input - f / 4) * (2 * Math.PI) / f) + 1);
}
});
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
PointF c = (PointF) animation.getAnimatedValue();
mDBCx = c.x;
mDBCy = c.y;
invalidate();
}
});
valueAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
mState = DEFAULT;
if (mBubbleStateListener == null) return;
mBubbleStateListener.onReset();
}
});
valueAnimator.start();
}
其实这个demo还没写完,气泡dismiss的动画没有写,这个是因为懒得找爆炸的桢图了(其实是被数学虐惨了 - -!)
封装的求切点的方法其实还是有点bug的,有时候计算会出现not a number的异常,应该是负数开方导致的,具体这点跪求知情者帮忙解惑!感谢~~
最后 源码点击查看