美文网首页
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