自定义View之QQ小红点(一)

前言

之前没有见到有封装好的类似QQ小红点的控件,虽然公司项目中并没有使用到该效果,不过出于练习与回顾的角度决定自己动手写一个。

贝塞尔曲线

在开始动手写之前,我先介绍一下贝塞尔曲线。贝赛尔曲线(Bézier曲线)是电脑图形学中相当重要的参数曲线。更高维度的广泛化贝塞尔曲线就称作贝塞尔曲面,其中贝塞尔三角是一种特殊的实例。贝塞尔曲线于1962年,由法国工程师皮埃尔·贝塞尔(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。贝塞尔曲线最初由Paul de Casteljau于1959年运用de Casteljau算法开发,以稳定数值的方法求出贝塞尔曲线。

线性贝塞尔曲线

给定点P0、P1,线性贝塞尔曲线只是一条两点之间的直线。这条线由下式给出:


这里写图片描述
这里写图片描述

二次方贝塞尔曲线

二次方贝塞尔曲线的路径由给定点P0、P1、P2的函数B(t)追踪:


这里写图片描述
这里写图片描述
这里写图片描述

三次方贝塞尔曲线

P0、P1、P2、P3四个点在平面或在三维空间中定义了三次方贝塞尔曲线。曲线起始于P0走向P1,并从P2的方向来到P3。一般不会经过P1或P2;公式如下:


这里写图片描述
这里写图片描述
这里写图片描述

N次方贝塞尔曲线

这里写图片描述
这里写图片描述

贝塞尔曲线代码实现

Android在API=1的时候就提供了贝塞尔曲线的画法,只是隐藏在Path#quadTo()Path#cubicTo()方法中,一个是二阶贝塞尔曲线,一个是三阶贝塞尔曲线。当然,如果你想自己写个方法,依照上面贝塞尔的表达式也是可以的。不过一般没有必要,因为Android已经在native层为我们封装好了二阶和三阶的函数。

效果分析

首先咱们来看看QQ的效果图,如下:


这里写图片描述

看到这个效果图,结合上述对贝塞尔的简单描述。你是否已经有想法了?如果有那么请跟着我继续验证你的想法,如果没有请容我向你娓娓道来。

1.首先确定有两个点,这两个点是不一样的。下面是原始红点,上面的手势拖拽之后产生的红点。原始点大小会随着拖拽的距离而逐渐变大,并且当达到阀值会消失。而拖拽点大小始终与原始大小一致保持不变。


这里写图片描述

2.两点之间的曲线效果。如下图,两条线段,其实就是两条二阶贝塞尔曲线。咱们只要分析出其中一条便可,就拿线1来说吧。贝塞尔曲线1的起点是两个小圆与大圆在某一侧的外切点,控制点是两圆点构成的线段的中心点。


这里写图片描述

3.如何实现动态变化?这个就好说了,手指移动的时候,小圆不断变化,切点自然也在变,并且两个圆的中心距离位置也是随着改变。

代码实现

梳理完毕之后,咱们就开始撸代码吧。再来回顾前面说的核心内容:1.贝塞尔曲线知识;2.两个圆的变化,以及利用贝塞尔曲线绘制两个圆拉动的效果,并且随着距离变化而变化。OK,下面开始看代码

首先创建类DragPointView并且继承View

public class DragPointView extends View {...}

成员变量

这里为了简便,一些可配属性直接写死。后续完善的时候会将属性抽离出来,供使用者可以通过自定义属性控制该控件的样式。

    public static final int DEFAULT_WIDTH = 23; // 默认宽度 单位:dp
    public static final int DEFAULT_HEIGHT = 23; // 默认高度 单位:dp

    private Paint mPaint; // 画笔 绘制的是圆和贝塞尔曲线路径 外貌一样
    private Path mPath; // 存储贝塞尔曲线
    private int width,height; // 控件宽高
    private boolean isInCircle; // 判断DOWN事件是否有效
    private float downX,downY; // 按下的位置
    private PointF[] mDragTangentPoint; // 两个圆切点中位于拖拽圆上的两个点
    private PointF[] mCenterTangentPoint; // 两个圆切点中位于初始圆上的两个点
    private PointF mCenterCircle; // 初始圆圆心
    private PointF mCenterCircleCopy; // 初始圆圆心copy
    private PointF mDragCircle; // 拖拽圆圆心
    private PointF mDragCircleCopy; // 拖拽圆圆心copy
    private double mDistanceCircles; // 两个圆心距离
    private PointF mControlPoint; // 贝塞尔曲线控制点 两条曲线控制点一致
    private boolean mIsOut; // 拖拽是否超出范围
    private ValueAnimator mRecoveryAnim; // 没超过范围时圆的恢复动画

    // 初始半径,运动时初始圆半径,拖拽圆半径
    private float mRadius, mRatioRadius, mDragRadius;
    private int mDragLength = 500; // 最长允许拖拽长度
    private float mDragMinRatio = 0.5f; // 初始圆允许最小比
    private long mRecoveryDuration = 200l; // 恢复动画时长
    private long mFrameDuration = 200l; // 气泡帧动画时长

    /**
     * 初始化操作
     */
    private void init() {
        mPath = new Path();
        mPaint = new Paint();
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setTextSize(18f);
        mPaint.setColor(0xffff0000);
        mPaint.setAntiAlias(true);
        mPaint.setDither(true);
        mDragRadius = mRadius = DensityUtil.dip2px(getContext(),23)/2;
        mDragTangentPoint = new PointF[2];
        mCenterTangentPoint = new PointF[2];
        mControlPoint = new PointF();
        mCenterCircle = new PointF();
        mCenterCircleCopy = new PointF();
        mDragCircle = new PointF();
        mDragCircleCopy = new PointF();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        width = w;
        height = h;
        mCenterCircle.x = width/2;
        mCenterCircle.y = height/2;
    }

测量过程

自定义控件时候要注意处理宽高为MeasureSpec.AT_MOST的情况,一般做法:设置默认宽高或者根据内容需要设置宽高。此外,由于咱们的自定义控件是直接继承View,如果需要的话还得为其支持Padding属性(这里简单说一下怎么支持,主要两处:1.测量的时候要考虑padding 2.绘制的时候考虑padding),小红点这个控件我觉得就没必要了哈。略过~

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        if(heightMode == MeasureSpec.AT_MOST){
            heightSize = DensityUtil.dip2px(getContext(),DEFAULT_HEIGHT);
        }
        if(widthMode == MeasureSpec.AT_MOST){
            widthSize = DensityUtil.dip2px(getContext(),DEFAULT_WIDTH);
        }
        setMeasuredDimension(widthSize,heightSize);
    }

绘制过程

代码很简练,主要是就三个方法。大概说一下逻辑。drawCenterCircle判断若没有拖拽超出范围则绘制初始圆,并且圆的半径是要根据mDistanceCircles、mDragLength以及mDragMinRatio实时计算的。drawDragCircle就没有太多逻辑了。drawBezierLine中将获取到的四个切点配合贝塞尔曲线连接为封闭空间后绘制即可。这里先理清逻辑就行,后续到具体方法具体分析~

    @Override
    protected void onDraw(Canvas canvas) {
        drawCenterCircle(canvas);
        if(isInCircle){
            drawDragCircle(canvas);
            drawBezierLine(canvas);
        }
    }

绘制初始圆

首先,如果此时手势动作已超出定义的范围。那么直接return~
否则,计算出应该绘制的圆的半径,简单的比例乘除~

    private void drawCenterCircle(Canvas canvas) {
        if(mIsOut) return;
        mRatioRadius = mRadius;
        if(isInCircle && mDragMinRatio < 1.f){
            mRatioRadius = (float) (Math.max((mDragLength - mDistanceCircles)*1.f/ mDragLength, mDragMinRatio) * mRadius);
        }
        canvas.drawCircle(mCenterCircle.x,mCenterCircle.y, mRatioRadius,mPaint);
    }

绘制拖拽圆

    private void drawDragCircle(Canvas canvas) {
        canvas.drawCircle(mDragCircle.x, mDragCircle.y, mRadius,mPaint);
    }

绘制贝塞尔曲线部分

这里写图片描述

首先,找到两个圆形成的4个切点p1,p2,p3,p4,此处使用的方式通过指定圆心与一条已知斜率的直线去获取切点数学不好的我也没办法啦,因为我也懵懵懂懂数学什么的忘得差不多了,但是请记住,这不是重点,哈哈。

其次,找到控制点,有人说了:“这个我会”~

最后,将四个点用Path连接并绘制,切记最后要使路径为闭合空间。p3-p4直连,p4-p1贝塞尔曲线,p1-p2直连,p2-p3贝塞尔曲线~ OK

    private void drawBezierLine(Canvas canvas) {
        if(mIsOut) return;
        float dx = mDragCircle.x - mCenterCircle.x;
        float dy = mDragCircle.y - mCenterCircle.y;
        // 控制点
        mControlPoint.set((mDragCircle.x + mCenterCircle.x) / 2,
                (mDragCircle.y + mCenterCircle.y) / 2);
        // 四个切点
        if (dx != 0) {
            float k1 = dy / dx;
            float k2 = -1 / k1;
            mDragTangentPoint = MathUtil.getIntersectionPoints(
                    mDragCircle.x, mDragCircle.y, mDragRadius, (double) k2);
            mCenterTangentPoint = MathUtil.getIntersectionPoints(
                    mCenterCircle.x, mCenterCircle.y, mRatioRadius, (double) k2);
        } else {
            mDragTangentPoint = MathUtil.getIntersectionPoints(
                    mDragCircle.x, mDragCircle.y, mDragRadius, (double) 0);
            mCenterTangentPoint = MathUtil.getIntersectionPoints(
                    mCenterCircle.x, mCenterCircle.y, mRatioRadius, (double) 0);
        }
        // 路径构建
        mPath.reset();
        mPath.moveTo(mCenterTangentPoint[0].x, mCenterTangentPoint[0].y);
        mPath.quadTo(mControlPoint.x, mControlPoint.y,mDragTangentPoint[0].x,mDragTangentPoint[0].y);
        mPath.lineTo(mDragTangentPoint[1].x, mDragTangentPoint[1].y);
        mPath.quadTo(mControlPoint.x, mControlPoint.y,
                mCenterTangentPoint[1].x, mCenterTangentPoint[1].y);
        mPath.close();
        canvas.drawPath(mPath,mPaint);
    }

    /**
     * Get the point of intersection between circle and line.
     * 获取 通过指定圆心,斜率为lineK的直线与圆的交点。
     *
     * @param radius The circle radius.
     * @param lineK The slope of line which cross the pMiddle.
     * @return
     */
    public static PointF[] getIntersectionPoints(float cx,float cy, float radius, Double lineK) {
        PointF[] points = new PointF[2];

        float radian, xOffset = 0, yOffset = 0;
        if(lineK != null){

            radian= (float) Math.atan(lineK);
            xOffset = (float) (Math.cos(radian) * radius);
            yOffset = (float) (Math.sin(radian) * radius);
        }else {
            xOffset = radius;
            yOffset = 0;
        }
        points[0] = new PointF(cx + xOffset, cy + yOffset);
        points[1] = new PointF(cx - xOffset, cy - yOffset);

        return points;
    }

这时候大家有疑问啦,LZ是不是把触摸事件给漏了~,漏不了,这就来

触摸事件

DOWN,MOVE,CANCEL,UP事件,需要做什么?重绘是肯定的~

DOWN事件:判断点击位置是否处于初始圆范围内,此处直接判断的是否位于圆的外切矩形内,当然如果你非要往细的扣,可以使用Region

MOVE事件:记录拖拽圆的圆心位置,即事件位置。计算两个圆心的距离,并且判断是否超过阀值啦。

CANCEL与UP事件:无非就是越界与否的判断,加上不同结果对应的动画效果

这里想到个事,如果的长动画或者其他类似的,记得在onDetachedFromWindow方法中处理一下,否则造成内存泄漏

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if(mRecoveryAnim ==null || !mRecoveryAnim.isRunning()) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    downX = event.getX();
                    downY = event.getY();
                    isInCircle = isInPointCircle(downX, downY);
                    postInvalidate();
                    break;
                case MotionEvent.ACTION_MOVE:
                    mDragCircle.x = event.getX();
                    mDragCircle.y = event.getY();
                    mDistanceCircles = MathUtil.getDistance(mCenterCircle, mDragCircle);
                    mIsOut = mIsOut ? mIsOut : mDistanceCircles > mDragLength;
                    postInvalidate();
                    break;
                case MotionEvent.ACTION_UP:
                case MotionEvent.ACTION_CANCEL:
                    upAndCancelEvent();
                    break;
            }
        }
        return true;
    }

    /**
     *  Gets the distance between two points.
     *  获取两点之间的距离
     *
     * @param p1
     * @param p2
     * @return
     */
    public static double getDistance(PointF p1,PointF p2){
        return Math.sqrt((p1.x-p2.x)*(p1.x-p2.x)+(p1.y-p2.y)*(p1.y-p2.y));
    }

    private void upAndCancelEvent() {
        if(isInCircle && mDistanceCircles == 0) {
            reset();
        }else if(!mIsOut){
            mCenterCircleCopy.set(mCenterCircle.x, mCenterCircle.y);
            mDragCircleCopy.set(mDragCircle.x, mDragCircle.y);
            if(mRecoveryAnim == null){
                mRecoveryAnim = ValueAnimator.ofFloat(0.f,1.5f);
                mRecoveryAnim.setDuration(mRecoveryDuration);
                mRecoveryAnim.setInterpolator(new AccelerateInterpolator());
                mRecoveryAnim.addUpdateListener(this);
                mRecoveryAnim.addListener(this);
            }
            mRecoveryAnim.start();
        }else{
            reset();
        }
    }

    @Override
    public void onAnimationUpdate(ValueAnimator valueAnimator) {
        float value = (float) valueAnimator.getAnimatedValue();
        mDragCircle.x = mDragCircleCopy.x + (mCenterCircleCopy.x - mDragCircleCopy.x)*value;
        mDragCircle.y = mDragCircleCopy.y + (mCenterCircleCopy.y - mDragCircleCopy.y)*value;
        postInvalidate();
    }

    @Override
    public void onAnimationEnd(Animator animator) {
        reset();
    }

    private void reset() {
        mIsOut = false;
        isInCircle = false;
        mDragCircle.x = mCenterCircle.x;
        mDragCircle.y = mCenterCircle.y;
        mDistanceCircles = 0;
        postInvalidate();
    }

效果图

这里写图片描述

总结

1.贝塞尔曲线简单介绍
2.QQ小红点效果分析
3.将分析所得代码实现

tip:目前只是小demo,诸多地方不完善。后续会考虑封装成开源库放到github上

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342