先看看最终效果:
Screenrecorder-2021-07-04-10-10-24-2122021741025364.gif这个自定义控件可以实现与绑定控件解耦,无论是什么控件,只要一行代码绑定即可实现拖拽的效果,并且可以拖动到标题栏的位置。
现在分3步走实现图中的效果:
(1)画两个圆,定点圆随着与拖动点圆的距离的增大而半径减小,手指放开后回复原状。
(2)在第一步两圆之间画贝塞尔曲线。
(3)将自定义控件添加到WindowManager,获取被绑定控件的Bitmap,实现任意控件可绑定。
一、画两个圆,定点圆半径随着两圆心距离增大而减小
先看着第一步的效果:
Screenrecorder-2021-07-04-10-06-52-1102021741014461.gif
这个效果的实现很简单,就是在自定义View上画两个圆,定点圆和拖动圆。初始时圆心重合,然后监听onTouch触摸事件。之后第一步监听move事件触点坐标,使得拖动圆坐标随手指坐标移动,重绘实现原点随手指移动。onTouch事件的处理:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 确保落点在圆点范围内,才去响应move事件
if (((event.getX() > underPoint.x - dip2px((float) mRadius)) && (event.getX() < underPoint.x + dip2px((float) mRadius))) &&
((event.getY() > underPoint.y - dip2px((float) mRadius)) && (event.getY() < underPoint.y + dip2px((float) mRadius)))) {
movePoint.set(event.getX(), event.getY());
} else {
return false;
}
break;
case MotionEvent.ACTION_MOVE:
// 更新拖动点的坐标
updatePoint(event.getX(), event.getY());
break;
case MotionEvent.ACTION_UP:
pointReset(getWidth() / 2, getHeight() / 2);
default:
pointReset(getWidth() / 2, getHeight() / 2);
break;
}
invalidate();
return true;
}
第二步,用勾股定理计算出move事件坐标与定点圆中心坐标距离,实现定点圆半径的变化。绘制方法如下:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制拖动圆
canvas.drawCircle(movePoint.x, movePoint.y, dip2px((float) mRadius), circlePaint);
// 两圆心大于一定距离后不再绘制定点圆
if (distanceCount < 10) {
canvas.drawCircle(underPoint.x, underPoint.y, dip2px((float) (mRadius / distanceCount)), circlePaint);
}
}
这只是消息拖拽效果的第一部分实现,这部分功能的完整代码如下:
public class MessageViewCurrent extends View {
private Paint circlePaint;
//拖拽圆及定点圆的位置
private PointF movePoint, underPoint;
// 拖拽圆半径
private final double mRadius = 20;
// 圆心距与直径之比
private double distanceCount = 1;
public MessageViewCurrent(Context context) {
this(context, null);
}
public MessageViewCurrent(Context context, @Nullable @org.jetbrains.annotations.Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MessageViewCurrent(Context context, @Nullable @org.jetbrains.annotations.Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initRes(context, attrs, defStyleAttr);
}
private void initRes(Context context, AttributeSet attrs, int defStyleAttr) {
circlePaint = new Paint();
circlePaint.setAntiAlias(true);
circlePaint.setDither(true);
circlePaint.setColor(context.getResources().getColor(R.color.design_default_color_primary_dark));
movePoint = new PointF();
underPoint = new PointF();
post(() -> { // 测量后再获取宽高
pointReset(getWidth() / 2, getHeight() / 2);
invalidate();
});
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 确保落点在圆点范围内,才去响应move事件
if (((event.getX() > underPoint.x - dip2px((float) mRadius)) && (event.getX() < underPoint.x + dip2px((float) mRadius))) &&
((event.getY() > underPoint.y - dip2px((float) mRadius)) && (event.getY() < underPoint.y + dip2px((float) mRadius)))) {
movePoint.set(event.getX(), event.getY());
} else {
return false;
}
break;
case MotionEvent.ACTION_MOVE:
// 更新拖动点的坐标
updatePoint(event.getX(), event.getY());
break;
case MotionEvent.ACTION_UP:
pointReset(getWidth() / 2, getHeight() / 2);
default:
pointReset(getWidth() / 2, getHeight() / 2);
break;
}
invalidate();
return true;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制拖动圆
canvas.drawCircle(movePoint.x, movePoint.y, dip2px((float) mRadius), circlePaint);
if (distanceCount < 10) {
// 两圆心大于一定距离后不再绘制定点圆
canvas.drawCircle(underPoint.x, underPoint.y, dip2px((float) (mRadius / distanceCount)), circlePaint);
}
}
private double getPointDistance() {
// 勾股定理求两点的距离
return Math.sqrt(Math.abs(movePoint.x - underPoint.x) * Math.abs(movePoint.x - underPoint.x) +
Math.abs(movePoint.y - underPoint.y) * Math.abs(movePoint.y - underPoint.y));
}
/**
* 计算两圆心距离与拖动圆直径之比
*/
private void countDistance() {
if (getPointDistance() > 2 * mRadius) {
distanceCount = getPointDistance() / (2 * mRadius);
}
}
/**
* 重置原点与拖动点
*
* @param x
* @param y
*/
public void pointReset(float x, float y) {
movePoint.set(x, y);
underPoint.set(x, y);
}
/**
* 更新拖动点的位置
*
* @param x
* @param y
*/
public void updatePoint(float x, float y) {
if (movePoint != null) {
movePoint.x = x;
movePoint.y = y;
countDistance();
}
}
private float dip2px(float dip) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dip, getResources().getDisplayMetrics());
}
}
二、绘制两圆间的贝塞尔曲线
贝塞尔曲线的原理这里不做分析了,可以看看这个贝塞尔曲线分析
下面来看贝塞尔曲线在这个消息拖拽控件当中的应用。看下面的草图:
Android中贝塞尔曲线的生成使用的是 Path.quadTo()这个方法,这个方法需要3个点:起始点、终点、控制点。在这个自定义View当中需要绘制两条贝塞尔曲线,也就是上图中红色的两条曲线。现在我们只要求出4个顶点p0、p1、p2、p3以及控制点k的坐标即可画出两条曲线。
其中在触摸事件发生时,已知的条件是两个圆的圆心坐标c0和c1,以及两圆的半径。可以直接求的两圆心坐标的水平方向的距离和数值方向的距离 delta X 和 delta Y 。然后就可以用三角函数间接求出图中 ∠a。求出 ∠a后,点 p0 与圆心 c0 的水平方向及竖直方向的距离 x 和 y 即可分别用三角函数求出。又已知圆心点 c0,那即可求出顶点 p0 的坐标,其他几个顶点p1、p2、p3 同理可求。至于控制点 k,这里且用两圆心的中点作为 k 的坐标。
这里提一下,图中的delta X和delta Y是分别用 c0 和 c1的横纵坐标相减求得的。随着拖动圆在定点圆四周滑动,delta X和delta Y 都有可能是正的,也有可能是负的。所以图中的 ∠a及 x、y都有可能是正的或负的。下面是求顶点 p0 的公式:
delta X = c1.x - c0.x
delta Y = c1.y - c0.y
tan ∠a = delta Y / delta X
所以 ∠a = arcTan (delta Y / delta X)
x = uRadiu * sin a
y = uRadiu * cos a
其中 uRadiu 是定点圆的半径。
然后即可求出 p0 的坐标:
p0.x = c0.x + x
p0.y = c0.y - y
下面是求贝塞尔曲线的完整方法 :
/**
* 计算贝塞尔曲线
*
* @return path
*/
private Path getBezPath() {
Path bezPath = new Path();
float underRadiusPx = dip2px((float) (mRadius / distanceCount));
float moveRadiusPx = dip2px((float) mRadius);
// 计算 delta X 、delta Y 及 ∠a 的值 arcTanA
float Dx = movePoint.x - underPoint.x;
float Dy = movePoint.y - underPoint.y;
double arcTanA = Math.atan(Dy / Dx);
// 计算 p0 及 p3 坐标
float dx0 = (float) (underRadiusPx * Math.sin(arcTanA));
float dy0 = (float) (underRadiusPx * Math.cos(arcTanA));
float x0 = underPoint.x + dx0;
float y0 = underPoint.y - dy0;
float x3 = underPoint.x - dx0;
float y3 = underPoint.y + dy0;
// 计算 p1 及 p2 坐标
float dx1 = (float) (moveRadiusPx * Math.sin(arcTanA));
float dy1 = (float) (moveRadiusPx * Math.cos(arcTanA));
float x1 = movePoint.x + dx1;
float y1 = movePoint.y - dy1;
float x2 = movePoint.x - dx1;
float y2 = movePoint.y + dy1;
// 画出包含两条贝塞尔曲线的闭合曲线
bezPath.moveTo(x0, y0);
float controlX = (underPoint.x + movePoint.x) / 2;
float controlY = (underPoint.y + movePoint.y) / 2;
bezPath.quadTo(controlX, controlY, x1, y1);
bezPath.lineTo(x2, y2);
bezPath.quadTo((underPoint.x + movePoint.x) / 2, (underPoint.y + movePoint.y) / 2, x3, y3);
bezPath.close();
return bezPath;
}
接下来在上一步已画出两个圆的基础上,在onDraw里再绘制已经计算出的贝塞尔曲线路径即可。onDraw方法:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制拖动圆
canvas.drawCircle(movePoint.x, movePoint.y, dip2px((float) mRadius), circlePaint);
if (distanceCount < 10) {
// 两圆心大于一定距离后不再绘制定点圆
canvas.drawCircle(underPoint.x, underPoint.y, dip2px((float) (mRadius / distanceCount)), circlePaint);
// 获取并绘制贝塞尔曲线
Path bezPath = getBezPath();
canvas.drawPath(bezPath, circlePaint);
}
}
这一步实现之后的效果如下:
Screenrecorder-2021-07-04-10-07-30-4872021741015402.gif
三、将该自定义控件添加到WindowManager,并实现一行代码可绑定任意控件。
这一步要实现的效果就如文章开始的地方。实现该自定义View与被绑定拖拽的控件解耦。
就如文章开始的效果图,这里总体的原理是将上面两步实现的可画贝塞尔曲线的自定义的MessageView通过 WindowManager的addView方法添加到 Window当中。然后获取绑定控件的Bitmap,监听该控件的 onTouch事件。控件的触摸事件触发后就将该控件设置为不可见,然后再手指滑动的过程当中,在已添加到Window里的自定义MessageView里不断重绘绑定控件的Bitmap。这样即可实现绑定控件被拖动的效果。
绑定控件的Bitmap获取:
/**
* 创建并获取View的Bitmap
*
* @param view view
* @return
*/
public Bitmap getViewBitmap(View view) {
view.buildDrawingCache();
return view.getDrawingCache();
}
onDraw方法:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// canvas.drawCircle(movePoint.x, movePoint.y, dip2px((float) mRadius), circlePaint);
if (distanceCount < 10) {
// 大于一定距离后原点消失
canvas.drawCircle(underPoint.x, underPoint.y, dip2px((float) (mRadius / distanceCount)), circlePaint);
// 获取并绘制贝塞尔曲线
Path bezPath = getBezPath();
canvas.drawPath(bezPath, circlePaint);
}
// 绘制被绑定拖拽控件的 Bitmap
canvas.drawBitmap(drawBitmap, movePoint.x - drawBitmap.getWidth() / 2,
movePoint.y - drawBitmap.getHeight() / 2, null);
}
可以看到,canvas不再绘制拖动圆,而是绘制了绑定控件的Bitmap。
所以,这里跟随手指滑动的并不是被绑定控件本身,而是用该控件获取的Bitmap。当滑动出一定的距离手指放开后,再实现粉碎的动画效果,然后给外界回调即可。
最后自定义View的完整代码:
public class MessageView extends View {
private Paint circlePaint;
// 定点圆和拖拽点圆的圆心
private PointF movePoint, underPoint;
// 拖拽圆半径
private final double mRadius = 20;
private double distanceCount = 1;
// 绑定控件的事件监听
private static MessageViewOnTouchListener mMessageViewOnTouchListener;
// 绑定控件的Bitmap
private static Bitmap drawBitmap;
// 爆炸粉碎效果切图
private ArrayList<Integer> bombBitmapIds = new ArrayList<>();
private int currentIndex = 0;
public MessageView(Context context) {
this(context, null);
}
public MessageView(Context context, @Nullable @org.jetbrains.annotations.Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MessageView(Context context, @Nullable @org.jetbrains.annotations.Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initRes(context, attrs, defStyleAttr);
}
private void initRes(Context context, AttributeSet attrs, int defStyleAttr) {
circlePaint = new Paint();
circlePaint.setAntiAlias(true);
circlePaint.setDither(true);
circlePaint.setColor(context.getResources().getColor(R.color.design_default_color_primary_dark));
movePoint = new PointF();
underPoint = new PointF();
bombBitmapIds.add(R.mipmap.explode_1);
bombBitmapIds.add(R.mipmap.explode_2);
bombBitmapIds.add(R.mipmap.explode_3);
bombBitmapIds.add(R.mipmap.explode_4);
bombBitmapIds.add(R.mipmap.explode_5);
}
private double getPointDistance() {
// 求两点的距离
return Math.sqrt(Math.abs(movePoint.x - underPoint.x) * Math.abs(movePoint.x - underPoint.x) +
Math.abs(movePoint.y - underPoint.y) * Math.abs(movePoint.y - underPoint.y));
}
/**
* 计算两圆心距离与拖动圆直径之比
*/
private void countDistance() {
if (getPointDistance() > 2 * mRadius) {
distanceCount = getPointDistance() / (2 * mRadius);
}
}
/**
* 生成贝塞尔曲线
*
* @return
*/
private Path getBezPath() {
Path bezPath = new Path();
float underRadiusPx = dip2px((float) (mRadius / distanceCount));
float moveRadiusPx = dip2px((float) mRadius);
// 计算 delta X 、delta Y 及 ∠a 的值 arcTanA
float Dx = movePoint.x - underPoint.x;
float Dy = movePoint.y - underPoint.y;
double arcTanA = Math.atan(Dy / Dx);
// 计算 p0 及 p3 坐标
float dx0 = (float) (underRadiusPx * Math.sin(arcTanA));
float dy0 = (float) (underRadiusPx * Math.cos(arcTanA));
float x0 = underPoint.x + dx0;
float y0 = underPoint.y - dy0;
float x3 = underPoint.x - dx0;
float y3 = underPoint.y + dy0;
// 计算 p1 及 p2 坐标
float dx1 = (float) (moveRadiusPx * Math.sin(arcTanA));
float dy1 = (float) (moveRadiusPx * Math.cos(arcTanA));
float x1 = movePoint.x + dx1;
float y1 = movePoint.y - dy1;
float x2 = movePoint.x - dx1;
float y2 = movePoint.y + dy1;
// 画出包含两条贝塞尔曲线的闭合曲线
bezPath.moveTo(x0, y0);
float controlX = (underPoint.x + movePoint.x) / 2;
float controlY = (underPoint.y + movePoint.y) / 2;
bezPath.quadTo(controlX, controlY, x1, y1);
bezPath.lineTo(x2, y2);
bezPath.quadTo((underPoint.x + movePoint.x) / 2, (underPoint.y + movePoint.y) / 2, x3, y3);
bezPath.close();
return bezPath;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// canvas.drawCircle(movePoint.x, movePoint.y, dip2px((float) mRadius), circlePaint);
if (distanceCount < 10) {
// 大于一定距离后原点消失
canvas.drawCircle(underPoint.x, underPoint.y, dip2px((float) (mRadius / distanceCount)), circlePaint);
// 获取并绘制贝塞尔曲线
Path bezPath = getBezPath();
canvas.drawPath(bezPath, circlePaint);
}
// 绘制被绑定拖拽控件的 Bitmap
canvas.drawBitmap(drawBitmap, movePoint.x - drawBitmap.getWidth() / 2,
movePoint.y - drawBitmap.getHeight() / 2, null);
}
public void pointReset(float x, float y) {
movePoint.set(x, y);
underPoint.set(x, y);
invalidate();
}
/**
* 更新手指移动点
*
* @param x
* @param y
*/
public void updatePoint(float x, float y) {
if (movePoint != null) {
movePoint.x = x;
movePoint.y = y;
countDistance();
invalidate();
}
}
public void setActionUp(View v, MotionEvent event) {
if (distanceCount < 10) {
// 重新显示原来的View
v.setVisibility(View.VISIBLE);
// 从WindowManager 移除 MessageView,释放焦点
mMessageViewOnTouchListener.removeView();
} else { // View 粉碎
showBomb(bombBitmapIds.get(0), v);
}
}
/**
* 实现爆炸的动画效果
*
* @param id
* @param v
*/
private void showBomb(int id, View v) {
if (id == R.mipmap.explode_5) {
currentIndex = 0;
// 从WindowManager 移除 MessageView,释放焦点
mMessageViewOnTouchListener.removeView();
// 接口通知外界
setDismiss(v);
return;
}
postDelayed(() -> {
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), id);
setDrawBitmap(bitmap);
invalidate();
currentIndex += 1;
showBomb(bombBitmapIds.get(currentIndex), v);
}, 100);
}
private float dip2px(float dip) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dip, getResources().getDisplayMetrics());
}
// 设置拖动控件的Bitmap
public void setDrawBitmap(Bitmap viewBitmap) {
drawBitmap = viewBitmap;
}
/**
* 绑定拖拽控件
*
* @param view
*/
public static void bindView(View view) {
if (view == null) return;
if (mMessageViewOnTouchListener == null)
mMessageViewOnTouchListener = new MessageViewOnTouchListener(view.getContext());
view.setOnTouchListener(mMessageViewOnTouchListener);
}
/**
* 释放 Context
*/
public void release() {
if (drawBitmap != null) {
drawBitmap.recycle();
drawBitmap = null;
}
if (mMessageViewOnTouchListener == null) return;
mMessageViewOnTouchListener.release();
mMessageViewOnTouchListener = null;
}
public interface ViewDismissListener {
void viewDismiss(View view);
}
private static ViewDismissListener mViewDismissListener;
public static void setViewDismissListener(ViewDismissListener viewDismissListener) {
mViewDismissListener = viewDismissListener;
}
private void setDismiss(View view) {
if (mViewDismissListener != null) mViewDismissListener.viewDismiss(view);
}
}
还要加上绑定控件事件监听的类:
public class MessageViewOnTouchListener implements View.OnTouchListener {
private MessageView mMessageView;
private WindowManager mWindowManager;
private Context mContext;
private WindowManager.LayoutParams mParams;
// ACTION_DOWN 落点的位置
private float downX, downY;
public MessageViewOnTouchListener(Context context) {
super();
mContext = context;
mMessageView = new MessageView(mContext);
mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
mParams = new WindowManager.LayoutParams();
// 透明
mParams.format = PixelFormat.TRANSPARENT;
// 设置外部可点击
mParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
}
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
// 添加 MessageView
addView();
// 获取绑定 View 的Bitmap
setBitmap(v);
// 隐藏绑定的View
v.setVisibility(View.INVISIBLE);
// 记录 ACTION_DOWN 落点的位置,用以计算绘制图形的初始位置
downX = event.getX();
downY = event.getY();
// 开始绘制
mMessageView.pointReset(event.getRawX() - downX + v.getWidth() / 2,
event.getRawY() - StatusBarUtil.getStatusBarHeight(v.getContext()) - downY + v.getHeight() / 2);
break;
case MotionEvent.ACTION_MOVE:
// 重绘
mMessageView.updatePoint(event.getRawX() - downX + v.getWidth() / 2,
event.getRawY() - StatusBarUtil.getStatusBarHeight(v.getContext()) - downY + v.getHeight() / 2);
break;
case MotionEvent.ACTION_UP:
mMessageView.setActionUp(v, event);
break;
default: break;
}
return true;
}
/**
* 往Window添加自动逸View
*/
public void addView(){
if (mMessageView == null) return;
mWindowManager.addView(mMessageView, mParams);
}
/**
* 移除View释放焦点
*/
public void removeView() {
if (mWindowManager == null) return;
mWindowManager.removeView(mMessageView);
}
public void release(){
mContext = null;
}
private void setBitmap(View view){
mMessageView.setDrawBitmap(getViewBitmap(view));
}
/**
* 创建并获取View的Bitmap
*
* @param view view
* @return
*/
public Bitmap getViewBitmap(View view) {
view.buildDrawingCache();
return view.getDrawingCache();
}
}
完整的Demo代码在:Github源码
网友评论