仿QQ拖动删除未读消息个数气泡之二

在仿QQ拖动删除未读消息个数气泡这篇文章中,模仿了QQ的删除未读消息气泡,不过也遗留了一个问题,当时为了让气泡能够在全屏范围内拖动,不能将其放在布局文件xml中,而是采用了在主布局加载完成后用addView方法动态加载气泡,这种方式不太好,因为它需要自己计算在全屏范围中,气泡放在什么地方,这需要参造物,而且如果把气泡放在listview中的话,也不可能给每个Item都去动态计算增加气泡,所以这种方式不够完善。


经过尝试,改用另一种方式来处理,可以将气泡放置在布局xml中,也不需要用addView来动态添加了。这篇博客就来讲解气泡的全屏拖动,而随手指移动用贝塞尔曲线画连接请参考上篇博客,这里不再重复讲解。来看效果

仿QQ拖动删除未读消息个数气泡之二_第1张图片


先说下原理。为了实现气泡的全屏拖动,我们需要两个自定义控件,一个RoundNumber,它就是放置在XML中的气泡控件,不过它并不随着手势移动,因为它的大小是有限的,移动了也看不到,而另外一个BounceCircle,它的大小是全屏的,随手势移动的是它。RoundNumber可以有多个,但BounceCircle只有一个。初始状态时,BounceCircle是隐藏的,RoundNumber是显示的,当我们按下RoundNumber时,立即将BounceCircle显示,而将RoundNumber隐藏,要注意,这时手指移动依然会是在RoundNumber的onTouchEvent事件中,而不是BounceCircle的,为了让BounceCircle随手势移动,我们要在RoundNumber的onTouchEvent中,调用回调方法,而最终调用到BounceCircle的onDraw函数,来实现随手势移动。


下面看具体实现,首先是RoundNumber

public class RoundNumber extends View {
    private int radius; // 圆形半径
    private float circleX; // 圆心x坐标
    private float circleY; // 圆心y坐标


    private Paint circlePaint; // 圆形画笔
    private TextPaint textPaint; // 文字画笔
    private int textSize; // 字体大小,单位SP
    private Paint.FontMetrics textFontMetrics; // 字体
    private float textMove; // 为了让文字居中,需要移动的距离


    private String message = "1";
    private boolean firstInit = true;
    private Context mContext;


    private ClickListener mClickListener;


    public RoundNumber(Context context, AttributeSet attrs) {
        super(context, attrs);


        mContext = context;


        initPaint();
    }


    /**
     * 初始化
     */
    private void initPaint() {
        circlePaint = new Paint();
        circlePaint.setColor(Color.RED);
        circlePaint.setAntiAlias(true);


        textPaint = new TextPaint();
        textPaint.setAntiAlias(true);
        textPaint.setColor(Color.WHITE);
    }


    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);


        if (firstInit) {
            firstInit = false;


            radius = w / 2;
            int[] position = new int[2];
            getLocationOnScreen(position);


            circleX = radius;
            circleY = radius;


            textSize = radius; // 根据圆半径来设置字体大小
            textPaint.setTextAlign(Paint.Align.CENTER);
            textPaint.setTextSize(Util.sp2px(mContext, textSize));
            textFontMetrics = textPaint.getFontMetrics();
            textMove = -textFontMetrics.ascent - (-textFontMetrics.ascent + textFontMetrics.descent) / 2; // drawText从baseline开始,baseline的值为0,baseline的上面为负值,baseline的下面为正值,即这里ascent为负值,descent为正值,比如ascent为-20,descent为5,那需要移动的距离就是20 - (20 + 5)/ 2
        }
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);


        canvas.drawCircle(circleX, circleY, radius, circlePaint);
        canvas.drawText(message, circleX, circleY + textMove, textPaint);
    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch(event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (MainActivity.isTouchable) {
                    if (mClickListener != null) {
                        MainActivity.isTouchable = false;
                        getParent().requestDisallowInterceptTouchEvent(true); // 不允许父控件处理TouchEvent,当父控件为ListView这种本身可滑动的控件时必须要控制
                        mClickListener.onDown();
                    }
                    return true;
                }


                return false;
            case MotionEvent.ACTION_MOVE:
                if (mClickListener != null) { // 注意这里要用getRaw来获取手指当前所处的相对整个屏幕的坐标
                    mClickListener.onMove(event.getRawX(), event.getRawY() - Util.getTopBarHeight((Activity)  mContext));
                }
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                if (mClickListener != null) {
                    getParent().requestDisallowInterceptTouchEvent(false); // 将控制权还给父控件
                    mClickListener.onUp();
                }
                break;
            default:
                break;
        }
        return super.onTouchEvent(event);
    }


    /**
     * 设置显示内容
     * @param message
     */
    public void setMessage(String message) {
        this.message = message;
    }


    /**
     * 获取显示内容
     * @return
     */
    public String getMessage() {
        return message;
    }


    public interface ClickListener {
        void onDown(); // 手指按下


        void onMove(float curX, float curY); // 手指移动


        void onUp(); // 手指抬起
    }


    public void setClickListener(ClickListener listener) {
        mClickListener = listener;
    }
}


可以看到,RoundNumber 这个自定义控件,它本身的onDraw方法中,只画了一个红色圆,和圆中的白色数字,而在它的onTouchEvent中,我们都是使用的都是类似mClickListener.***这样的方法,而这个mClickListener是我们自己定义的一个接口,它用来回调,在回调中去真正处理随手势移动的逻辑。另外,这里还需要注意的是,在手指按下时,我们调用了getParent().requestDisallowInterceptTouchEvent(true);方法,这是用来让父控件放弃控制权,因为在一些本身可以随手指移动的控件,例如ListView,不加这个处理,会导致事件被父类消费了,所以这里要控制一下,同样道理,手指抬起时,要调用getParent().requestDisallowInterceptTouchEvent(false);将控制权还给父类。


另外这里还用到了一个全局变量MainActivity.isTouchable,这个值初始情况为true,在有RoundNumber被按下时,就会赋值为false,完后等手指抬起,处理完动画后,才会重新变成true,设置这样一个变量是为了防止同时有多个RoundNumber被处理,因为只有一个BounceCircle。完后在MainActivity中去设置回调

unreadMessage = (RoundNumber) findViewById(R.id.unread_message);
        unreadMessage.setMessage("3");
        unreadMessage.setClickListener(new RoundNumber.ClickListener() {
            @Override
            public void onDown() {
                int[] position = new int[2];
                unreadMessage.getLocationOnScreen(position);

                int radius = unreadMessage.getWidth() / 2;
                circle.down(radius, position[0] + radius, position[1] - Util.getTopBarHeight(MainActivity.this) + radius, unreadMessage.getMessage());
                circle.setVisibility(View.VISIBLE); // 显示全屏范围的BounceCircle

                unreadMessage.setVisibility(View.INVISIBLE); // 隐藏固定范围的RoundNumber
                circle.setOrginView(unreadMessage);
            }

            @Override
            public void onMove(float curX, float curY) {
                circle.move(curX, curY);
            }

            @Override
            public void onUp() {
                circle.up();
            }
        });

这里的unreadMessage是RoundNumber,而circle是BounceCircle,可以看到,最终的处理还是在BounceCircle中


BounceCircle的代码如下:

public class BounceCircle extends View {
    private Context mContext;

    private Paint circlePaint; // 圆形/连线画笔
    private TextPaint textPaint; // 文字画笔
    private Paint.FontMetrics textFontMetrics; // 字体
    private Path path;

    private int radius; // 移动圆形半径
    private float textMove; // 为了让文字居中,需要移动的距离

    private float curX; // 当前x坐标
    private float curY; // 当前y坐标
    private float circleX; // 固定圆的圆心x坐标
    private float circleY; // 固定圆的圆心y坐标
    private float ratio = 1; // 圆缩放的比例,随着手指的移动,固定的圆越来越小
    private float ratioLimit = 0.2f; // 固定圆最小的缩放比例,小于该比例时就直接消失
    private int distanceLimit = 100; // 固定圆和移动圆的圆心之间距离的限值,单位DP(配合ratioLimit使用)
    private int textSize; // 字体大小,单位SP

    private int animationTime = 200; // 抖动动画执行的时间
    private int animationTimes = 1; //  抖动动画执行次数
    private boolean needDraw = true; // 是否需要执行onDraw方法

    private FinishListener mFinishListener; // 自定义接口,用来回调
    private String message = "1"; // 显示的数字的初始值

    private Bitmap[] explosionAnim; // 爆炸动画
    private boolean animStart; // 动画开始
    private int animNumber = 5; // 动画帧的个数
    private int curAnimNumber; // 动画播放的当前帧
    private int animInterval = 200; // 动画帧之间的间隔
    private int animWidth; // 动画帧的宽度
    private int animHeight; // 动画帧的高度

    private View originalView;

    public BounceCircle(Context context, AttributeSet attrs) {
        super(context, attrs);

        mContext = context;

        initPaint();
    }

    /**
     * 初始化Paint
     */
    private void initPaint() {
        circlePaint = new Paint();
        circlePaint.setColor(Color.RED);
        circlePaint.setAntiAlias(true);

        distanceLimit = Util.dip2px(mContext, distanceLimit);

        textPaint = new TextPaint();
        textPaint.setAntiAlias(true);
        textPaint.setColor(Color.WHITE);

        path = new Path();
    }

    /**
     * 初始化爆炸动画
     */
    private void initAnim() {
        if (explosionAnim == null) {
            explosionAnim = new Bitmap[animNumber];
            explosionAnim[0] = BitmapFactory.decodeResource(getResources(), R.mipmap.explosion_one);
            explosionAnim[1] = BitmapFactory.decodeResource(getResources(), R.mipmap.explosion_two);
            explosionAnim[2] = BitmapFactory.decodeResource(getResources(), R.mipmap.explosion_three);
            explosionAnim[3] = BitmapFactory.decodeResource(getResources(), R.mipmap.explosion_four);
            explosionAnim[4] = BitmapFactory.decodeResource(getResources(), R.mipmap.explosion_five);

            // 动画每帧的长宽都是一样的,取一个即可
            animWidth = explosionAnim[0].getWidth();
            animHeight = explosionAnim[0].getHeight();
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        if (needDraw) {
            // 画固定圆
            if (ratio >= ratioLimit) {
                canvas.drawCircle(circleX, circleY, radius * ratio, circlePaint);
            }

            // 画移动圆和连线
            if (curX != 0 && curY != 0) {
                canvas.drawCircle(curX, curY, radius, circlePaint);
                if (ratio >= ratioLimit) {
                    drawLinePath(canvas);
                }
            }

            // 数字要最后画,否则会被连线遮掩
            if (curX != 0 && curY != 0) { // 移动圆里面的数字
                canvas.drawText(message, curX, curY + textMove, textPaint);
            } else { // 只有初始时需要绘制固定圆里面的数字
                canvas.drawText(message, circleX, circleY + textMove, textPaint);
            }
        }

        if (animStart) { // 动画进行中
            if (curAnimNumber < animNumber) {
                canvas.drawBitmap(explosionAnim[curAnimNumber], curX - animWidth / 2, curY - animHeight / 2, null);
                curAnimNumber++;
                if (curAnimNumber == 1) { // 第一帧立即执行
                    invalidate();
                } else { // 其余帧每隔固定时间执行
                    postInvalidateDelayed(animInterval);
                }
            } else { // 动画结束
                animStart = false;
                curAnimNumber = 0;
                recycleBitmap();
                setVisibility(View.INVISIBLE); // 隐藏BounceCircle
                curX = 0;
                curY = 0;

                MainActivity.isTouchable = true;

                // 删除后的回调
                if (mFinishListener != null) {
                    mFinishListener.onFinish();
                }
            }
        }
    }

    /**
     * 回收Bitmap资源
     */
    private void recycleBitmap() {
        if (explosionAnim != null && explosionAnim.length != 0) {
            for (int i = 0; i < explosionAnim.length; i++) {
                if (explosionAnim[i] != null && !explosionAnim[i].isRecycled()) {
                    explosionAnim[i].recycle();
                    explosionAnim[i] = null;
                }
            }

            explosionAnim = null;
        }
    }

    /**
     * 画固定圆和移动圆之间的连线
     * @param canvas
     */
    private void drawLinePath(Canvas canvas) {
        path.reset();

        float distance = (float) Util.distance(circleX, circleY, curX, curY); // 移动圆和固定圆圆心之间的距离
        float sina = (curY - circleY) / distance; // 移动圆圆心和固定圆圆心之间的连线与X轴相交形成的角度的sin值
        float cosa = (circleX - curX) / distance; // 移动圆圆心和固定圆圆心之间的连线与X轴相交形成的角度的cos值

        path.moveTo(circleX - sina * radius * ratio, circleY - cosa * radius * ratio); // A点坐标
        path.lineTo(circleX + sina * radius * ratio, circleY + cosa * radius * ratio); // AB连线
        path.quadTo((circleX + curX) / 2, (circleY + curY) / 2, curX + sina * radius, curY + cosa * radius); // 控制点为两个圆心的中间点,二阶贝塞尔曲线,BC连线
        path.lineTo(curX - sina * radius, curY - cosa * radius); // CD连线
        path.quadTo((circleX + curX) / 2, (circleY + curY) / 2, circleX - sina * radius * ratio, circleY - cosa * radius * ratio); // 控制点也是两个圆心的中间点,二阶贝塞尔曲线,DA连线

        canvas.drawPath(path, circlePaint);
    }

    /**
     * 计算固定圆缩放的比例
     * @param distance
     * @return
     */
    private void calculateRatio(float distance) {
        ratio = (distanceLimit - distance) / distanceLimit;
    }

    /**
     * 抖动动画
     * @param counts
     */
    public void shakeAnimation(int counts) {
        // 避免动画抖动的频率过大,所以除以2,另外,抖动的方向跟手指滑动的方向要相反
        Animation translateAnimation = new TranslateAnimation((circleX - curX) / 2, 0, (circleY - curY) / 2, 0);
        translateAnimation.setInterpolator(new CycleInterpolator(counts));
        translateAnimation.setDuration(animationTime);
        startAnimation(translateAnimation);

        translateAnimation.setAnimationListener(new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) {
            }

            @Override
            public void onAnimationEnd(Animation animation) { // 抖动动画结束时,显示以前的RoundNumber,隐藏BounceCircle
                if (originalView != null) {
                    originalView.setVisibility(View.VISIBLE);
                }

                setVisibility(View.INVISIBLE);

                MainActivity.isTouchable = true;
            }

            @Override
            public void onAnimationRepeat(Animation animation) {
            }
        });
    }

    public interface FinishListener {
        void onFinish();
    }

    public void setFinishListener(FinishListener finishListener) {
        mFinishListener = finishListener;
    }

    public void move(float curX, float curY) {
        this.curX = curX;
        this.curY = curY;
        calculateRatio((float) Util.distance(curX, curY, circleX, circleY));

        invalidate();
    }

    public void up() {
        if (ratio > ratioLimit) { // 没有超出最大移动距离,手抬起时需要让移动圆回到固定圆的位置
            shakeAnimation(animationTimes);

            curX = 0;
            curY = 0;
            ratio = 1;


        } else { // 超出最大移动距离
            needDraw = false;
            animStart = true;

            initAnim();
        }

        invalidate();
    }

    public void down(int radius, float circleX, float circleY, String message) {
        needDraw = true; // 由于BounceCircle是公用的,每次进来时都要确保needDraw的值为true

        this.radius = radius;
        this.circleX = circleX;
        this.circleY = circleY;
        this.message = message;

        textSize = radius;
        textPaint.setTextAlign(Paint.Align.CENTER);
        textPaint.setTextSize(Util.sp2px(mContext, textSize));
        textFontMetrics = textPaint.getFontMetrics();
        textMove = -textFontMetrics.ascent - (-textFontMetrics.ascent + textFontMetrics.descent) / 2; // drawText从baseline开始,baseline的值为0,baseline的上面为负值,baseline的下面为正值,即这里ascent为负值,descent为正值,比如ascent为-20,descent为5,那需要移动的距离就是20 - (20 + 5)/ 2

        invalidate();
    }

    /**
     * 设置按下时被隐藏的View
     * @param view
     */
    public void setOrginView(View view) {
        originalView = view;
    }
}


 
  

这个跟上一篇博客中讲的基本是一样的,唯一的区别是这里没有onTouchEvent的处理,因为手势的按键响应在RoundNumber的onTouchEvent中,完后将事件传递到Bouncecircle,再通过down,move和up三个方法来处理手势的最终操作

源码下载

你可能感兴趣的:(Android之旅)