美文网首页
Android自定义View(7)利用贝塞尔曲线仿QQ消息拖拽气

Android自定义View(7)利用贝塞尔曲线仿QQ消息拖拽气

作者: 碧云天EthanLee | 来源:发表于2021-07-04 15:54 被阅读0次

先看看最终效果:

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());
    }
}

二、绘制两圆间的贝塞尔曲线

贝塞尔曲线的原理这里不做分析了,可以看看这个贝塞尔曲线分析
下面来看贝塞尔曲线在这个消息拖拽控件当中的应用。看下面的草图:

Inkedbezer_LI.jpg

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源码

相关文章

网友评论

      本文标题:Android自定义View(7)利用贝塞尔曲线仿QQ消息拖拽气

      本文链接:https://www.haomeiwen.com/subject/ijumultx.html