模仿手机QQ红点消除功能

标签: androidqq红点消除
3764人阅读 评论(10) 收藏 举报
分类:

简介

手机QQ红点消除的功能大家应该印象很深,我一直奇怪微信为什么不跟进这个功能,毕竟消息太多。
功能图如下:
这里写图片描述

简单的功能描述是这样的:新消息到来以后,会出现红点,红点被拉扯,在短距离内出现粘连效果,到达一点距离以后,可以扯断粘连,松手消除红点。

对于这个功能是怎么实现的呢,我一直很好奇,并且参考了一下两篇文章:
Android之实现妙趣横生的粘连布局
手机 QQ 的一键消除红点功能是怎么想出来的?

本篇文章实现了该效果,自定义了控件AdherentLayout,并且通过简单的叙述,让大家了解实现该功能的原理。
效果图:
这里写图片描述

原理介绍

比较容易让人迷惑的地方,是拉扯以后,两个红点(初始位置的红点,和用户手指下的红点)之间的粘连效果。
这个效果的本质,是通过贝塞尔曲线绘制的两条曲线。
关于贝塞尔曲线的原理,可以看百度百科
简单而言,就是对于二阶贝塞尔曲线,就是通过三个点确定一条曲线。其中两个点为端点,分别位于曲线两端,第三个点为锚点,用于控制曲线的形状。
这里写图片描述
这里写图片描述

对于红点消除而言,两个圆点之间的切线,就可以确定曲线的端点,我们再取两圆心的中点作为锚点,即可绘制曲线。
对于贝塞尔曲线,Android已经提供了一个原生方法:

public void quadTo (float x1, float y1, float x2, float y2)
Added in API level 1
Add a quadratic bezier from the last point, approaching control point (x1,y1), and ending at (x2,y2). If no moveTo() call has been made for this contour, the first point is automatically set to (0,0).
Parameters
x1 The x-coordinate of the control point on a quadratic curve
y1 The y-coordinate of the control point on a quadratic curve
x2 The x-coordinate of the end point on a quadratic curve
y2 The y-coordinate of the end point on a quadratic curve

代码中是这样体现的:

/**
     * 画贝塞尔曲线
     * 
     * @param canvas
     */ 
    private void drawBezier(Canvas canvas) {

        /* 求三角函数 */     
        float atan = (float) Math.atan((mFooterCircle.cury - mHeaderCircle.cury) / (mFooterCircle.curx - mHeaderCircle.curx)); 
        float sin = (float) Math.sin(atan);
        float cos = (float) Math.cos(atan);

        /* 四个点 */       
        float headerX1 = mHeaderCircle.curx - mHeaderCircle.curRadius * sin;
        float headerY1 = mHeaderCircle.cury + mHeaderCircle.curRadius * cos;

        float headerX2 = mHeaderCircle.curx + mHeaderCircle.curRadius * sin;
        float headerY2 = mHeaderCircle.cury - mHeaderCircle.curRadius * cos;

        float footerX1 = mFooterCircle.curx - mFooterCircle.curRadius * sin;
        float footerY1 = mFooterCircle.cury + mFooterCircle.curRadius * cos;

        float footerX2 = mFooterCircle.curx + mFooterCircle.curRadius * sin;
        float footerY2 = mFooterCircle.cury - mFooterCircle.curRadius * cos;

        float anchorX = ( mHeaderCircle.curx + mFooterCircle.curx ) / 2;
        float anchorY = ( mHeaderCircle.cury + mFooterCircle.cury ) / 2;

        /* 画贝塞尔曲线 */        
        mPath.reset();        
        mPath.moveTo(headerX1, headerY1);
        mPath.quadTo(anchorX, anchorY, footerX1, footerY1);
        mPath.lineTo(footerX2, footerY2);
        mPath.quadTo(anchorX, anchorY, headerX2, headerY2);
        mPath.lineTo(headerX1, headerY1);
        canvas.drawPath(mPath, mPaint);        
    }          

设计思路

1、属性列表

public class AdherentLayout extends RelativeLayout {
    private Circle mHeaderCircle = new Circle();
    private Circle mFooterCircle = new Circle();

    //画笔     
    private Paint mPaint = new Paint();
    //画贝塞尔曲线的Path对象     
    private Path mPath = new Path();
    //粘连的颜色     
    private int mColor = Color.rgb(247,82,49);       
    //是否粘连着     
    private boolean isAdherent = true;    
    //本View初始宽度、高度    
    private int mOriginalWidth;
    private int mOriginalHeight;
    //是否第一次onSizeChanged    
    private boolean isFirst = true;
    //用户添加的视图(可以不添加)    
    private View mView;
    //是否正在进行动画中    
    private boolean isAnim = false;
    //记录按下的x、y   
    float mDownX;
    float mDownY;
    //本View的左上角x、y    
    private float mX;
    private float mY;
    //父控件左、上内边距    
    private float mParentPaddingLeft;
    private float mParentPaddingTop;
    //默认粘连的最大长度     
    private float mMaxAdherentLength = 1000;
    //头部圆缩小时不能小于这个最小半径     
    private float mMinHeaderCircleRadius = 4;   
    //是否允许可以扯断     
    private boolean isDismissed = true;   
    //是否按下    
    boolean isDown = false;
    ...
}

另外定义了一个圆点内部类

/**
     * 圆点类
     * @author Administrator     
     */
    private class Circle{
        /**
         * 初始坐标x,y
         */
        float ox;
        float oy;
        /**
         * 当前坐标x,y
         */
        float curx;
        float cury;
        //初始半径
        float originalRadius;
        //当前半径
        float curRadius;
    }

2、圆点绘制

通过效果图可以观察到,本质手指拉扯圆点以后,是出现了两个红点,一个留在原地,一个随着手指移动。
在自定义的AdherentLayout的ondraw()方法中,绘制了这两个圆点,初始状态下,圆点的中心在AdherentLayout的中心,因此我们需要在onSizeChanged(int w, int h, int oldw, int oldh)内测量控件宽高,计算出圆点的中心坐标

@Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);      
        if (isFirst && w > 0 && h > 0) {                        
            mView = getChildAt(0);
            //记录初始宽高,用于复原
            mOriginalWidth = w;
            mOriginalHeight = h;   
            ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) getLayoutParams();
            mX = getX()-lp.leftMargin;//起始位置
            mY = getY()-lp.topMargin;
            ViewGroup mViewGroup = (ViewGroup) getParent();                  
            if(mViewGroup!=null){
                mParentPaddingLeft = mViewGroup.getPaddingLeft();
                mParentPaddingTop = mViewGroup.getPaddingTop();
            }
            reset(); 
            isFirst = false;
        }        
    }

    /**
     * 重置所有参数
     */
    public void reset() {
        setWidthAndHeight(mOriginalWidth, mOriginalHeight);
        mHeaderCircle.curRadius = mFooterCircle.curRadius = 
                mHeaderCircle.originalRadius = mFooterCircle.originalRadius = getRadius();//减去边距以后获得半径
        mFooterCircle.ox = mFooterCircle.curx =  mHeaderCircle.ox = mHeaderCircle.curx = mOriginalWidth/2;//取中心位置为圆心
        mFooterCircle.oy = mFooterCircle.cury =  mHeaderCircle.oy = mHeaderCircle.cury = mOriginalHeight / 2;
        if (mView != null) {
            if(isFirst){
                mView.setX(0);
                mView.setY(0);
            }else{
                mView.setX(getPaddingLeft());
                mView.setY(getPaddingTop());
            }           
        }
        isAnim = false;        
    }

    **
     * 根据内边距返回圆的半径
     * @return
     */
    private float getRadius(){
        return 
        (float)(Math.min(
                Math.min(mOriginalWidth/2-getPaddingLeft(),mOriginalWidth/2-getPaddingRight()),Math.min(mOriginalHeight/2-getPaddingTop(),mOriginalHeight/2getPaddingBottom())) - 2);        
    }

3、圆点脱离

在手指移动的时候,其中一个圆,我们称为mFooterCircle,将会随手指移动。
思路很直观,就是监听触控事件,down时记录下起始坐标
move时计算移动距离,然后根据这个距离去修改mFooterCircle的位置即可

@Override
    public boolean onTouchEvent(MotionEvent event) {
        super.onTouchEvent(event);

        if (isAnim) return true;

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                setWidthAndHeight(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);    
                //设置成MATCH_PARENT后,会重复计算一次父控件padding,所以在这里要减去
                mFooterCircle.ox = mFooterCircle.curx =  mHeaderCircle.ox = mHeaderCircle.curx = mX + mOriginalWidth/2-mParentPaddingLeft;
                mFooterCircle.oy = mFooterCircle.cury =  mHeaderCircle.oy = mHeaderCircle.cury = mY + mOriginalHeight/2-mParentPaddingTop;
                if (mView != null) {                    
                    mView.setX(mX+getPaddingLeft()-mParentPaddingLeft);
                    mView.setY(mY+getPaddingTop()-mParentPaddingTop);
                }
                mDownX = event.getRawX();
                mDownY = event.getRawY();  
                //标记按下
                isDown = true;
                break;
            case MotionEvent.ACTION_MOVE:
                if(!isDown) break;
                //偏移
                float detalX = event.getRawX()-mDownX;
                float detalY = event.getRawY()-mDownY;        

                mFooterCircle.curx = mFooterCircle.ox+detalX;
                mFooterCircle.cury = mFooterCircle.oy+detalY;                
                if (mView != null) {
                    mView.setX(mX+getPaddingLeft()+detalX-mParentPaddingLeft);
                    mView.setY(mY+getPaddingTop()+detalY-mParentPaddingTop);
                }
                doAdhere();
                break;
            case MotionEvent.ACTION_UP:
            ...
 }

4、绘制贝塞尔曲线,起始圆缩放

在move过程中,我们不止要重新绘制mFooterCircle,还要使位于起始位置的圆(称为mHeaderCircle),具有缩小的效果。
也就是距离越远,mHeaderCircle半径越小,这里设置了一个比例值和最小值,防止距离太远,mHeaderCircle太小。
另外两圆心之间的距离,还是贝塞尔曲线是否绘制的依据,当距离过大,我们就不绘制两圆之间的贝塞尔曲线。

判断距离是否过远的函数如下:

**
     * 处理粘连效果逻辑
     */
    private void doAdhere() {
        //两圆心的距离
        float distance = (float) Math.sqrt(Math.pow(mFooterCircle.curx - mHeaderCircle.ox, 2) + Math.pow(mFooterCircle.cury - mHeaderCircle.oy, 2));
        //缩放比例
        float scale = 1 - distance / mMaxAdherentLength;
        mHeaderCircle.curRadius = Math.max(mHeaderCircle.originalRadius * scale, mMinHeaderCircleRadius);
        if (distance > mMaxAdherentLength && isDismissed) {
            isAdherent = false;
            mHeaderCircle.curRadius = 0;
        }
        else
            isAdherent = true;
    }

如果isAdherent为true,就调用上文提及的贝塞尔曲线绘制方法即可。

5、松开手指,回弹动画

当距离过远,贝塞尔曲线不再绘制,松开手指,不会出现回弹,所以这里不讨论。
出现回弹,其本质是监听Action_UP事件,使控件产生一个动画,也就是mFooterCircle从当前位置,返回到起始位置,由于不是针对特定的view,我们使用ValueAnimator来计算返回过程中的过程值。
例如X方向

/* x方向 */
        ValueAnimator xValueAnimator = ValueAnimator.ofFloat(mFooterCircle.curx, mFooterCircle.ox);
        xValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mFooterCircle.curx = (float) (Float)animation.getAnimatedValue();
                invalidate();
            }
        });

只需要运行这个动画,动态修改mFooterCircle的X坐标即可。
整体动画过程如下:

/**
     * 开始粘连动画
     */
    private void startAnim() {

        /* x方向 */
        ValueAnimator xValueAnimator = ValueAnimator.ofFloat(mFooterCircle.curx, mFooterCircle.ox);
        xValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mFooterCircle.curx = (float) (Float)animation.getAnimatedValue();
                invalidate();
            }
        });

        /* y方向 */
        ValueAnimator yValueAnimator = ValueAnimator.ofFloat(mFooterCircle.cury, mFooterCircle.oy);
        yValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mFooterCircle.cury = (float) (Float)animation.getAnimatedValue();
                invalidate();
            }
        });

        /* 用户添加的视图x、y方向 */
        ObjectAnimator objectAnimator = null;        
        if (mView != null) {
            PropertyValuesHolder pvhX = PropertyValuesHolder.ofFloat("X", mFooterCircle.curx-mFooterCircle.curRadius-getPaddingLeft(), mX+getPaddingLeft()-mParentPaddingLeft);
            PropertyValuesHolder pvhY = PropertyValuesHolder.ofFloat("Y", mFooterCircle.cury-mFooterCircle.curRadius-getPaddingTop(), mY+getPaddingTop()-mParentPaddingTop);
            objectAnimator = ObjectAnimator.ofPropertyValuesHolder(mView, pvhX, pvhY);
        }

        /* 动画集合 */
        AnimatorSet animSet = new AnimatorSet();
        if (mView != null) 
            animSet.playTogether(xValueAnimator,yValueAnimator,objectAnimator);
        else
            animSet.playTogether(xValueAnimator,yValueAnimator);
        animSet.setInterpolator(new BounceInterpolator());
        animSet.setDuration(400);
        animSet.start();
        animSet.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                reset();
            }
        });        
    }

写在最后

实现红点效果消除的效果并不困难,难的是有想出这个效果的脑洞。。。
源码下载地址

24
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:570150次
    • 积分:10200
    • 等级:
    • 排名:第1731名
    • 原创:446篇
    • 转载:34篇
    • 译文:15篇
    • 评论:197条
    博客专栏
    最新评论