请尊重原创,转载请注明出处:http://blog.csdn.net/mabeijianxi/article/details/50560361
最近抽了些时间找了些资料,做了一个相对成熟的类似QQ小红点的拖拽控件。
先看下最后的效果:
simple与lib下载地址:https://github.com/mabeijianxi/stickyDots
一、分析:
1、首先分析这个控件的组成部分:
通过观察可以很明显的得出这个控件由三部分组成,一个固定不动的圆,一个连接部分,一个可能是圆的拖拽部分,由于不确定暂时把它看作圆
2、分析三个部分需要如何绘制。
(1)两个圆:这个比较简单,直接在复写view的onDraw方法,在里面执行canvas.drawCircle(),当然还需要传入圆心坐标和半径大小。
(2)连接部分:这个用过ps的矢量工具的应该知道。这里的是两条二阶贝塞尔曲线加两条直线。如图,二阶贝塞尔曲线是由起始点(P0,P2)和一个控制点(P1)组成。
二阶贝塞尔曲线在android中的绘制方法可以调用Path类:
Path mPath=new Path();
mPath.moveTo(P0.x,P0.y);
mPath.quadTo(P1..x, P1.y, P2.x, P2.y);
直线就比较简单了:
mPath.lineTo(L0.x,L0.y);
(3)如何把每个部分结合起来,并且绘制在屏幕上:
有一个圆的圆心是固定的,可以先绘制。完成以后需要绘制连接部分,这个连接部分有两条曲线,两条直线,所以需要5个点才能绘制出来,
其中1个贝塞尔曲线的控制点,因为对称,所以控制点两条曲线公用一个控制点,。至于剩下的四个点,这里选取两个圆的外切点,如图:
接下来就是计算了,首先拖拽圆的圆心、半径可以知道,圆心坐标就是你手指触摸的位置,可以重写onTouchEvent()得到,固定圆圆心是不会变的,至于半径暂时给个确定值。
第一步:计算外切点:
根据两圆心所连接成的执行计算斜率:
公式是k=dy/dy;
dy=O1.y-O2.y;
dx=O1.x-O2.x;
有了斜率、半径与圆心计算切点就没有问题了,都是三角函数的一些换算,就不多说,具体的可以下载或者查看这个工具类GeometryUtil的计算过程。
第二步:计算控制点,其实就是O1与O2的中心点,x=(O1.x+O2.x)/2 y=(O1.y+O2.y)/2
第三步:根据计算出来的五个点开始绘制闭合图形
这里我选择B点开始绘制:
移动到B点:mPath.moveTo(B.x,B.y)
从B点向A点作二阶贝塞尔曲线:mPath.quadTo(M.x,M.y,A.x,A.y)
从A向C绘制直线:mPath.lineTo(C.x,C.y)
从C向D绘制二阶贝塞尔曲线:mPath.quadTo(M.x,M.y,D.x,D.y)
直接封闭图形就行了:mPath.close()
二、静态图像绘制:
下面的静态绘制的代码:
package com.mabeijianxi.myapplication;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PointF;
import android.graphics.Rect;
import android.view.View;
/**
* Created by mabeijianxi on 2016/1/25.
*/
public class StickyView extends View {
/**
* 拖拽圆的圆心
*/
PointF mDragCanterPoint = new PointF(250, 450);
/**
* 固定圆的圆心
*/
PointF mFixCanterPoint = new PointF(250, 250);
/**
* 控制点
*/
PointF mCanterPoint = new PointF(250, 400);
/**
* 固定圆的切点
*/
PointF[] mFixTangentPointes = new PointF[] { new PointF(235, 250),
new PointF(265, 250) };
/**
* 拖拽圆的切点
*/
PointF[] mDragTangentPoint = new PointF[] { new PointF(230, 450),
new PointF(270, 450) };
/**
* 拖拽圆半径
*/
float mDragRadius = 20;
/**
* 固定圆半径
*/
float mFixRadius = 15;
private int statusBarHeight;
private Paint mPaint;
private Path mPath;
public StickyView(Context context) {
super(context);
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setAntiAlias(true);
mPath = new Path();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
canvas.translate(0, -statusBarHeight);
canvas.drawCircle(mFixCanterPoint.x, mFixCanterPoint.y, mFixRadius,
mPaint);
float dy = mDragCanterPoint.y - mFixCanterPoint.y;
float dx = mDragCanterPoint.x - mFixCanterPoint.x;
mCanterPoint.set((mDragCanterPoint.x + mFixCanterPoint.x) / 2,
(mDragCanterPoint.y + mFixCanterPoint.y) / 2);
if (dx != 0) {
float k1 = dy / dx;
float k2 = -1 / k1;
mDragTangentPoint = getIntersectionPoints(
mDragCanterPoint, mDragRadius, (double) k2);
mFixTangentPointes = getIntersectionPoints(
mFixCanterPoint, mFixRadius, (double) k2);
} else {
mDragTangentPoint = getIntersectionPoints(
mDragCanterPoint, mDragRadius, (double) 0);
mFixTangentPointes = getIntersectionPoints(
mFixCanterPoint, mFixRadius, (double) 0);
}
mPath.reset();
mPath.moveTo(mFixTangentPointes[0].x, mFixTangentPointes[0].y);
mPath.quadTo(mCanterPoint.x, mCanterPoint.y,
mDragTangentPoint[0].x, mDragTangentPoint[0].y);
mPath.lineTo(mDragTangentPoint[1].x, mDragTangentPoint[1].y);
mPath.quadTo(mCanterPoint.x, mCanterPoint.y,
mFixTangentPointes[1].x, mFixTangentPointes[1].y);
mPath.close();
canvas.drawPath(mPath, mPaint);
canvas.drawCircle(mDragCanterPoint.x, mDragCanterPoint.y,
mDragRadius, mPaint);
canvas.restore();
}
/** 获取状态栏高度
* @param v
* @return
*/
public static int getStatusBarHeight(View v) {
if (v == null) {
return 0;
}
Rect frame = new Rect();
v.getWindowVisibleDisplayFrame(frame);
return frame.top;
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
statusBarHeight=getStatusBarHeight(this);
}
/**
* Get the point of intersection between circle and line.
* 获取 通过指定圆心,斜率为lineK的直线与圆的交点。
*
* @param pMiddle The circle center point.
* @param radius The circle radius.
* @param lineK The slope of line which cross the pMiddle.
* @return
*/
public static PointF[] getIntersectionPoints(PointF pMiddle, float radius, Double lineK) {
PointF[] points = new PointF[2];
float radian, xOffset = 0, yOffset = 0;
if(lineK != null){
radian= (float) Math.atan(lineK);
xOffset = (float) (Math.cos(radian) * radius);
yOffset = (float) (Math.sin(radian) * radius);
}else {
xOffset = radius;
yOffset = 0;
}
points[0] = new PointF(pMiddle.x + xOffset, pMiddle.y + yOffset);
points[1] = new PointF(pMiddle.x - xOffset, pMiddle.y - yOffset);
return points;
}
}
Activity:
package com.mabeijianxi.myapplication;
import android.app.Activity;
import android.os.Bundle;
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(new StickyView(this));
}
}
运行结果:
是不是觉得挺简单的,下面开始动态绘制。
三、动态图形绘制:
有了绘制静态图的经验,绘制动态图将变得很简单,无非就是根据手指触摸的位置计算拖拽圆的圆心坐标、控制点的坐标与控制点的坐标,很明显
拖拽圆的坐标就是我们手指触摸的位置,固定圆坐标不变,那么其他的坐标的计算方法我们已经在二中已经知道了,比如控制点就是两圆心的中点。
切点可根据三角函数求出。公式全都有了,现在只需要动态绘制就可以了。
获取手指触摸的坐标,大家应该都会,直接上代码了:
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
float startX = event.getRawX();
float startY = event.getRawY();
updateDragCenterPoint(startX, startY);
break;
case MotionEvent.ACTION_MOVE:
float endX = event.getRawX();
float endY = event.getRawY();
// 更加手的移动位置绘制拖拽圆的位置
updateDragCenterPoint(endX, endY);
//
break;
}
return true;
}
/**
* 更新拖拽圆圆心
*/
private void updateDragCenterPoint(float x, float y) {
mDragCanterPoint.set(x, y);
invalidate();
}
运行示意图:
四、根据业务增加功能与动画。
1、观察分析:
a.固定圆虽然圆心不变,但是半径在变化,并且与两圆的圆心距成正比。
b.拖拽过程中有一个范围。
c.拖拽过程中根据拖拽范围有几个状态:①只在范围内移动,正常全部绘制。
②只范围内移动,最后在范围内松手,作回弹动画。
③范围外移动过,只要超出过一次将不再绘制固定圆与连接杆。
2、实现:
a.固定圆半径动态变化,这个可以更具拖拽圆与固定圆之间的距离来变化:
/**
* 计算拖动过程中固定圆的半径
*/
private float updateStickRadius() {
float distance = (float) Math.sqrt(Math.pow(mDragCanterPoint.y - mFixCanterPoint.y, 2)
+ Math.pow(mDragCanterPoint.x - mFixCanterPoint.x, 2));
distance = Math.min(distance, mFarthestDistance);
float percent = distance * 1.0f / mFarthestDistance;
return mFixRadius + (mMinFixRadius - mFixRadius) * percent;
}
b. 配置回弹动画,通过值动画来模拟数据,其实就是以拖拽圆为起点固定圆为终点不停的直线移动拖拽圆的坐标:
/**
* 移动的时候一直在范围内,最后在范围内松手
*/
private void inUp() {
final PointF startPoint = new PointF(mDragCanterPoint.x,
mDragCanterPoint.y);
final PointF endPoint = new PointF(mFixCanterPoint.x,
mFixCanterPoint.y);
ValueAnimator animator = ValueAnimator.ofFloat(1.0f);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float fraction = animation.getAnimatedFraction();
PointF byPercent = GeometryUtil.getPointByPercent(
startPoint, endPoint, fraction);
updateDragCenterPoint(byPercent.x, byPercent.y);
}
});
animator.setInterpolator(new OvershootInterpolator(4.0f));
animator.setDuration(500);
animator.start();
}
c. 增加状态标识,这里只需要两个就够了,一个代表是否超出过范围,一个代表现在是在范围外还是范围内。
/**
* 超出范围
*/
private boolean isOut;
/**
* 在超出范围的地方松手
*/
private boolean isOutUp;
只需要在触摸与绘制过程中动态改变或者判断它们就可以了。
以上分析得出code:
package com.mabeijianxi.myapplication;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PointF;
import android.graphics.Rect;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.OvershootInterpolator;
/**
* Created by mabeijianxi on 2016/1/25.
*/
public class StickyView extends View {
/**
* 最大拖拽范围
*/
private float mFarthestDistance = 200;
/**
* 动画中固定员的最小半径
*/
private float mMinFixRadius = 8;
/**
* 拖拽圆的圆心
*/
PointF mDragCanterPoint = new PointF(250, 250);
/**
* 固定圆的圆心
*/
PointF mFixCanterPoint = new PointF(250, 250);
/**
* 控制点
*/
PointF mCanterPoint = new PointF(250, 250);
/**
* 固定圆的切点
*/
PointF[] mFixTangentPointes = new PointF[]{new PointF(235, 250),
new PointF(265, 250)};
/**
* 拖拽圆的切点
*/
PointF[] mDragTangentPoint = new PointF[]{new PointF(230, 250),
new PointF(270, 250)};
/**
* 拖拽圆半径
*/
float mDragRadius = 20;
/**
* 固定圆半径
*/
float mFixRadius = 15;
/**
* 超出范围
*/
private boolean isOut;
/**
* 在超出范围的地方松手
*/
private boolean isOutUp;
private int mStatusBarHeight;
private Paint mPaint;
private Path mPath;
private float rangeMove;
public StickyView(Context context) {
super(context);
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setAntiAlias(true);
mPath = new Path();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
// 需要去除状态栏高度偏差
canvas.translate(0, -mStatusBarHeight);
// 移出了范围后将不再绘制链接部分和固定圆
if (!isOut) {
// 根据圆心距动态绘制固定圆大小
float mFixRadius = updateStickRadius();
canvas.drawCircle(mFixCanterPoint.x, mFixCanterPoint.y, mFixRadius,
mPaint);
// 设置控制点,这里的控制点选择的是两圆心连接成的直线的中心位置
mCanterPoint.set((mDragCanterPoint.x + mFixCanterPoint.x) / 2,
(mDragCanterPoint.y + mFixCanterPoint.y) / 2);
// 接下来是计算两个圆的外切点,会用到一点几何知识,忘了的回去找高中老师
float dy = mDragCanterPoint.y - mFixCanterPoint.y;
float dx = mDragCanterPoint.x - mFixCanterPoint.x;
if (dx != 0) {
float k1 = dy / dx;
float k2 = -1 / k1;
mDragTangentPoint = getIntersectionPoints(
mDragCanterPoint, mDragRadius, (double) k2);
mFixTangentPointes = getIntersectionPoints(
mFixCanterPoint, mFixRadius, (double) k2);
} else {
mDragTangentPoint = getIntersectionPoints(
mDragCanterPoint, mDragRadius, (double) 0);
mFixTangentPointes = getIntersectionPoints(
mFixCanterPoint, mFixRadius, (double) 0);
}
// 必须重设上一次的路径
mPath.reset();
// moveTo顾名思义就是移动到某个位置,这里移动到固定圆的第一个外切点
mPath.moveTo(mFixTangentPointes[0].x, mFixTangentPointes[0].y);
// quadTo是绘制二阶贝塞尔曲线,这种曲线很想ps里面画矢量路径的那种。二阶的话需要一个控制点,一个起点一个终点
mPath.quadTo(mCanterPoint.x, mCanterPoint.y,
mDragTangentPoint[0].x, mDragTangentPoint[0].y);
// 从上一个点绘制一条直线到下面这个位置
mPath.lineTo(mDragTangentPoint[1].x, mDragTangentPoint[1].y);
// 再绘制一条二阶贝塞尔曲线
mPath.quadTo(mCanterPoint.x, mCanterPoint.y,
mFixTangentPointes[1].x, mFixTangentPointes[1].y);
// 执行close,表示形成闭合路径
mPath.close();
// 绘制到界面上
canvas.drawPath(mPath, mPaint);
}
// 当在范围外松手的时候是不再绘制拖拽圆的
if (!isOutUp) {
canvas.drawCircle(mDragCanterPoint.x, mDragCanterPoint.y,
mDragRadius, mPaint);
}
// 参考范围,没实际作用
mPaint.setStyle(Paint.Style.STROKE);
canvas.drawCircle(mFixCanterPoint.x, mFixCanterPoint.y, mFarthestDistance, mPaint);
mPaint.setStyle(Paint.Style.FILL);
canvas.restore();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
isOut = false;
float startX = event.getRawX();
float startY = event.getRawY();
updateDragCenterPoint(startX, startY);
break;
case MotionEvent.ACTION_MOVE:
float endX = event.getRawX();
float endY = event.getRawY();
// 更加手的移动位置绘制拖拽圆的位置
updateDragCenterPoint(endX, endY);
distance();
// 当移出了规定的范围的时候
if (rangeMove > mFarthestDistance) {
isOut = true;
} else {
// 不能把isOut改为false,因为移出一次后就算它移出过了
// isOut=false;
isOutUp = false;
}
break;
case MotionEvent.ACTION_UP:
// 防止误操作
distance();
if (isOut) {
outUp();
}
// 没有超出,做动画
else {
inUp();
}
invalidate();
break;
}
return true;
}
/**
* 移动出规定范围
*/
private void outUp() {
// 外面松手
if (rangeMove > mFarthestDistance) {
isOutUp = true;
}
// 里面松手
else {
isOutUp = false;
}
updateDragCenterPoint(mFixCanterPoint.x, mFixCanterPoint.y);
}
/**
* 计算此时拖拽圆心到固定圆心的距离
*/
private void distance() {
rangeMove = getDistanceBetween2Points(
mFixCanterPoint, mDragCanterPoint);
}
/**
* 移动的时候一直在范围内,最后在范围内松手
*/
private void inUp() {
final PointF startPoint = new PointF(mDragCanterPoint.x,
mDragCanterPoint.y);
final PointF endPoint = new PointF(mFixCanterPoint.x,
mFixCanterPoint.y);
ValueAnimator animator = ValueAnimator.ofFloat(1.0f);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float fraction = animation.getAnimatedFraction();
PointF byPercent = getPointByPercent(
startPoint, endPoint, fraction);
updateDragCenterPoint(byPercent.x, byPercent.y);
}
});
animator.setInterpolator(new OvershootInterpolator(4.0f));
animator.setDuration(500);
animator.start();
}
/**
* 计算拖动过程中固定圆的半径
*/
private float updateStickRadius() {
float distance = (float) Math.sqrt(Math.pow(mDragCanterPoint.y - mFixCanterPoint.y, 2)
+ Math.pow(mDragCanterPoint.x - mFixCanterPoint.x, 2));
distance = Math.min(distance, mFarthestDistance);
float percent = distance * 1.0f / mFarthestDistance;
return mFixRadius + (mMinFixRadius - mFixRadius) * percent;
}
/**
* 更新拖拽圆圆心
*/
private void updateDragCenterPoint(float x, float y) {
mDragCanterPoint.set(x, y);
invalidate();
}
/**
* 获取状态栏高度
*
* @param v
* @return
*/
public static int getStatusBarHeight(View v) {
if (v == null) {
return 0;
}
Rect frame = new Rect();
v.getWindowVisibleDisplayFrame(frame);
return frame.top;
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mStatusBarHeight = getStatusBarHeight(this);
}
/**
* Get point between p1 and p2 by percent.
* 根据百分比获取两点之间的某个点坐标
* @param p1
* @param p2
* @param percent
* @return
*/
public static PointF getPointByPercent(PointF p1, PointF p2, float percent) {
return new PointF(evaluateValue(percent, p1.x , p2.x), evaluateValue(percent, p1.y , p2.y));
}
/**
* 根据分度值,计算从start到end中,fraction位置的值。fraction范围为0 -> 1
* @param fraction
* @param start
* @param end
* @return
*/
public static float evaluateValue(float fraction, Number start, Number end){
return start.floatValue() + (end.floatValue() - start.floatValue()) * fraction;
}
/**
* As meaning of method name.
* 获得两点之间的距离
* @param p0
* @param p1
* @return
*/
public static float getDistanceBetween2Points(PointF p0, PointF p1) {
float distance = (float) Math.sqrt(Math.pow(p0.y - p1.y, 2) + Math.pow(p0.x - p1.x, 2));
return distance;
}
/**
* Get the point of intersection between circle and line.
* 获取 通过指定圆心,斜率为lineK的直线与圆的交点。
*
* @param pMiddle The circle center point.
* @param radius The circle radius.
* @param lineK The slope of line which cross the pMiddle.
* @return
*/
public static PointF[] getIntersectionPoints(PointF pMiddle, float radius, Double lineK) {
PointF[] points = new PointF[2];
float radian, xOffset = 0, yOffset = 0;
if (lineK != null) {
radian = (float) Math.atan(lineK);
xOffset = (float) (Math.cos(radian) * radius);
yOffset = (float) (Math.sin(radian) * radius);
} else {
xOffset = radius;
yOffset = 0;
}
points[0] = new PointF(pMiddle.x + xOffset, pMiddle.y + yOffset);
points[1] = new PointF(pMiddle.x - xOffset, pMiddle.y - yOffset);
return points;
}
}
Activity:
package com.mabeijianxi.myapplication;
import android.app.Activity;
import android.os.Bundle;
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(new StickyView(this));
}
}
也许你觉得应该结束了,然而其实才刚刚开始,下面会涉及到WindowManager与event的一些操作。
1、分析:比如要像QQ一样添加到listview中应该怎么做?一个listview里面有很多条目,我们这个拖拽控件是在条目中的,也就是说如果要像一般控件一样
直接添加到条目中,那么他将不能做出这一系列的拖拽,因为只能在父控件的区域内绘制,也就只能在一个条目的范围内移动,那么怎么可以实现
全屏移动呢?除非它的parent大小充满整个屏幕,这时应该想到WindowManager。只要在需要的时候把它添加到WindowManager就可以了,那么
什么时候需要呢?很明显是当手指触摸到需要被拖拽的控件的时候,所以肯定需要给需要拖拽的控件设置触摸监听。那么问题又来了,我们写的自定
拖拽控件的拖拽部分是一个圆,我们需要被拖拽的控件可能是一坨狗屎也可能是一朵鲜花,怎么办呢?其实很简单,让我们需要拖拽的狗屎也添加到
WindowManager中,狗屎的中心坐标恒等于拖拽圆的圆心就可以。一个View如果需要加到一个父控件中,那么添加之前这个View坑定不能有parent,
没有哪个孩子是两个爹的,所以需要先view.getParent(),如果其parent instanceof viewGroup那么就可以remove掉这个view,当然remove之前还需要
先得到view的LayoutParams,因为你动画做完了还需要把这个view还给原来的listview的条目。这个方法是不错,但是在一个view被remove了以后再添加
回去,这时将会有些得不到,具体的代码里面有注释,其实就是得不到,view在屏幕上的绝对坐标,我能想到的就是这个view被移除后坑定也在这个当前
窗体里面被移除,将再添加回去的时候,可能没有再次绑定到窗体,所提得不到坐标,具体详解请参考如何取得View的位置之View.getLocationInWindow()的
小秘密,我尝试了很多次依然有bug,如果大家有解决办法一定要吝啬和我分享一下呗。于是我采取了另外一个办法,每隔需要被拖拽的狗屎在被触摸时将
被隐藏,然后会新建一个一模一样的狗屎用来做动画,这样的话不管新建的狗屎解决如何都不重要,当然这样对内容会有那么多一点点的消耗。
2、实践:这里添加了一个辅助类当作桥梁作用,请点击StickyViewHelper,具体操作方法请点击simple。
3、使用lib依赖说明:麻烦一点就是需要被拖拽的控件在布局文件中要单独指定,然后在需要使用的地方用include.像这样:
includeview:
item:
具体的请参照sample,内附详细注释。
不足之处欢迎指出!