自定义View:实现炫酷的点赞特效(仿即刻)

1.写在文前

按照惯例,反手就是一个超链接:
github地址

2.目标

本文要实现的View效果如下图:

效果图.gif

3.分析

从效果图容易看出,图中的功能主要分为两个部分:

  • 左侧大拇指动画
  • 右侧的文字动画

3.1 左侧(PraiseView)

不难发现左侧动画效果主要由三部分组成:

  1. MotionEvent_DOWN时的拇指缩小,UP时的放大效果
  2. MotionEvent_UP时的圆圈扩散效果(水波纹效果)
  3. MotionEvent_UP时的上面的四条线段效果

拇指的缩放各位客观想必也是心中有数的,无非就是两种方式:

  • 对整个View使用scale动画
  • 对View中的VectorDrawable使用scale动画
    细心的客观已经发现了当四条线段存在的时候,点击之后,线段也是会随之缩放的。没错,豆豆正是对整个View进行了scale处理。
    代码如下:
  // 处理拇指缩放效果
 @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                move = event.getY();
                animate().scaleY(0.8f).scaleX(0.8f).start();
                break;
            case MotionEvent.ACTION_UP:
                getHandler().postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        animate().cancel();
                        setScaleX(1);
                        setScaleY(1);
                    }
                }, 300);
                ...
                // 省略无关代码
                break;
        }
        return super.onTouchEvent(event);
    }

3.1.1 圆圈扩散

没错,就是画圈圈。同样,仔细的同志应该已经发现了些什么,冥冥之中似乎有些什么不可告人的秘密。
是的,这里有两个需要注意的地方:

  • 初始圆圈的半径,和中心位置,也就是圈圈该画在哪里(从图中不难看出,圆圈是包裹着拇指的)
  • measure出View的大小,确认drawable的bound(不自行measure确定view的大小的话,默认的大小是只会包裹drawable哦~)
    废话不多说,先看代码:
// 测量View宽高
@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        ...
        switch (widthSpecMode) {
            ...
            case MeasureSpec.AT_MOST:
                widthMeasureSpec = mDrawable.getIntrinsicWidth();
                break;
            ...
        }

        switch (heightSpecMode) {
            ...
            // wrap_content
            case MeasureSpec.AT_MOST:
                heightMeasureSpec = mDrawable.getIntrinsicHeight();
                break;
            ...
        }

        setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
        initDrawable(mDrawable, widthMeasureSpec, heightMeasureSpec);
        initPointFs(1.3f);
    }

    // drawable的大小为view的0.6
    private void initDrawable(Drawable drawable, int width, int height) {
        mCircleCenter.x = width / 2f;
        mCircleCenter.y = height / 2;
        mDrawable = drawable;

        // drawable的边长为view的0.6
        float diameter = (float) ((width > height ? height : width) * 0.6);

        int left = (int) ((width - diameter)/2);
        int top = (int)(height - diameter)/2;
        int right = (int) (left + diameter);
        int bottom = (int) (top + diameter);
        Rect drawableRect = new Rect(left, top, right, bottom);
        mDrawable.setBounds(drawableRect);
        requestLayout();
    }

由此计算出了view和drawable的大小,从而可以去画他了。这样我们就确认了圈圈该画在哪里,接下来的扩散效果,只需要控制圈圈的半径即可,依旧看代码:

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

        drawEffect(canvas);
    }

    private void drawEffect(Canvas canvas) {
        // 画圆
        if (mRadius > 0)
            canvas.drawCircle(mCircleCenter.x, mCircleCenter.y, mRadius, mPaint);
        if (drawLines == 1) {
            // 划线
            ...
    }

    public void animation() {
        final float radius = getInitRadius(mDrawable);
        ObjectAnimator animator = ObjectAnimator.ofFloat(this, "radius", radius, radius * 1.5f, radius * 3.0f);
        animator.setInterpolator(new AnticipateInterpolator());
        animator.setDuration(500);
        // 画线
        // ...
        set.start();
    }

至此我们完成了拇指的缩放和波纹效果,心里美滋滋有木有

3.1.2 线段效果

线段怎么去画呢?中小学老师告诉我们,两点确认一条线段。问题随之转换:

  • 那么我们如何确认这两点的位置呢?
  • 为了可持续发展,我们该怎么样去确定两条线段直接的距离呢?
    各位客官,不妨喝杯茶,吃点瓜子,思考下上面这个问题。
    ...
    ... // 优雅的喝茶timing
    ...
    细心的朋友已经注意到我之前的onMeasure方法中有一个initPointFs(1.3);没错,就是在获取View的大小后,进行了对点的计算,看代码:
    /**
     * 用于计算 线条的长度
     * @param scale 外圆半径为内圆半径的scale倍数
     */
    private void initPointFs(float scale) {
        mPointList.clear();
        float radius = getInitRadius(mDrawable);
        int base = -60;
        int factor = -20;
        for (int i = 0; i < 4; i++) {
            int result = base + factor * i;
            // 点p1为mDrawable外接圆上的点
            PointF p1 = new PointF(
                    mCircleCenter.x + (float) (radius * Math.cos(Math.toRadians(result))),
                    mCircleCenter.y + (float) (radius * Math.sin(Math.toRadians(result)))
            );

            // 点p1为mDrawable外接圆scale倍上的点
            PointF p2 = new PointF(
                    mCircleCenter.x + (float) (scale * radius * Math.cos(Math.toRadians(result))),
                    mCircleCenter.y + (float) (scale * radius * Math.sin(Math.toRadians(result)))
            );

            mPointList.add(p1);
            mPointList.add(p2);
        }
    }

通过代码注解不难发现,这里我们巧妙的利用同心圆和角度的方式来确定了4条线段,8个点集合的值(豆豆不禁感叹,数学对程序员的重要性)。这样做的好处就是足够灵活,无论View大小如何变,线段的间隔和长短都是适宜的。
至此左侧的拇指动画效果,算是告一段落了。

3.2 右侧(RecordView)

右边的数字翻牌效果,乍看起来很简单,无非就是drawText()累加之后重新drawText();原理上是这样的没错,不过值得注意的是:

  • 无需变化的数位上的值不会被翻动
  • 上下翻动时前一个数字会渐渐隐掉
    先看,测量过程:
    从图中我们不难发现,测量的高度值应当为Text的3倍,用于显示前一个,当前,和下一个的数字值
    宽度可以直接从api中获取当前的text的宽即可,看代码:
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        ···
        switch (widthSpecMode) {
            ···
            case MeasureSpec.AT_MOST:
                int width = (int) mPaint.measureText("0", 0, 1) * mCurrentString.length();
                widthMeasureSpec = width;
                break;
            ···
        }

        switch (heightSpecMode) {
            ···
            case MeasureSpec.AT_MOST:
                mTextHeight = mPaint.getFontSpacing();
                heightMeasureSpec = (int) (mTextHeight * 3);
                break;
            case MeasureSpec.EXACTLY:
                mPaint.setTextSize(heightSpecSize / 4);
                mTextHeight = (int) mPaint.getFontSpacing();
                heightMeasureSpec = heightSpecSize;
                break;
        }
        pointY = 2 * mTextHeight;
        setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
    }

在测量出View的宽高之后,便要着手去画view的内容了,而内容很简单,就是一系列的String值。到这里都比较容易实现,而难点则是,确定上一个和下一个值,以及他们的位置。
细心的朋友可能已经发现在measure的时候,我们有一个mTextHeigh记录了文字的高度,pointY记录了两倍文字的高度,没错这里就是利用mTextHeight来控制三个可能要画出来的string值的位置的。

这里有必要提一下的是,drawText(@NonNull String text, float x, float y, @NonNull Paint paint)这个方法中的float y对应的是baseLine的y值,简单的理解的话就是一串String的bottom的位置,画出来的内容是在bottom之上的。这也是为什么我们要用pointY = 2 * mTextHeight的理由。至此不难想到,我们的lastNum, currentNum, NextNum画的位置,分别对应mTextHeight, 2 * mTextHeight和3 * mTextHeight。至此三个值的位置便算是确定好了。

3.2.1 加1动画

先看加1的处理,上代码:

    public void addOne() {
        mCurrentString = String.valueOf(mCurrentNum);
        mCurrentNum++;
        mNextString = String.valueOf(mCurrentNum);
        mStatus = ADD;

        // 数字位数进1
        if (mCurrentString.length() < mNextString.length()) {
            mCurrentString = " " + mCurrentString;
            requestLayout();
        }

        ObjectAnimator animator = ObjectAnimator.ofFloat(this, "pointY", 2 * mTextHeight, mTextHeight);
        ObjectAnimator alphaAnim = ObjectAnimator.ofInt(this, "paintAlpha", 255, 0);
        AnimatorSet set = new AnimatorSet();
        set.playTogether(alphaAnim, animator);
        set.start();
    }

代码比较简单,无非是做了移动和透明度的动画效果,这里便解决了“上下翻动时前一个数字会渐渐隐掉”的需求,需要注意的点是,数字位进1时的利用空格占位的处理,不做该处理,当数字进位后,动画效果会差强人意,有兴趣的朋友可以去试试看。
结合onDraw方法再来看看:

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (mStatus == NONE) {
            canvas.drawText(mCurrentString, 0, pointY, mPaint);
        } else if (mStatus == ADD) {
            for (int i = mNextString.length() - 1; i >= 0; i--) {
                String next = String.valueOf(mNextString.charAt(i));
                String current = String.valueOf(mCurrentString.charAt(i));

                // i位置需要改变
                if (!next.equals(current)) {
                    mPaint.setAlpha(mPaintAlpha);
                    canvas.drawText(current, mPaint.measureText("0", 0, 1) * i, pointY, mPaint);

                    // mPaintAlpha : 255  -  0 递减
                    mPaint.setAlpha(255 - mPaintAlpha);
                    canvas.drawText(next, mPaint.measureText("0", 0, 1) * i, mTextHeight + pointY, mPaint);
                    // i位置不需要改变
                } else {
                    mPaint.setAlpha(255);
                    canvas.drawText(current, mPaint.measureText("0", 0, 1) * i, mTextHeight * 2, mPaint);
                }
            }
        } else if (mStatus == REDUCE) {
            // pointY是累加的,因此有个往下滑动效果
            for (int i = mCurrentString.length() - 1; i >= 0; i--) {
                String last = String.valueOf(mLastString.charAt(i));
                String current = String.valueOf(mCurrentString.charAt(i));

                // i位置需要改变
                if (!last.equals(current)) {
                    mPaint.setAlpha(mPaintAlpha);
                    canvas.drawText(current, mPaint.measureText("0", 0, 1) * i, mTextHeight + pointY, mPaint);

                    // mPaintAlpha : 255  -  0 递减
                    mPaint.setAlpha(255 - mPaintAlpha);
                    canvas.drawText(last, mPaint.measureText("0", 0, 1) * i, pointY, mPaint);
                    // i位置不需要改变
                } else {
                    mPaint.setAlpha(255);
                    canvas.drawText(current, mPaint.measureText("0", 0, 1) * i, mTextHeight * 2, mPaint);
                }
            }
        }
    }

这里便是核心所在了:如何无需变化的数位上的值不会被翻动?
onDraw方法中给出了我们答案,思路很简单:

  • 将接下来要显示的数字和当前的正在显示的数字的每一位数一一对比如果不同,则通过动画效果重画,相同,则不走动画效果,直接画出来即可。

至此gif图中的两部分效果都已经实现

3.3 整体(PraiseRecordView)

以上是分开独立的两个view,为了更方便的使用这个效果,我们需要将两个view的功能整合在一起,起到一个联动效果,也就需要引入一个ViewGroup去确定这两个view(PraiseView和RecordView)的布局,这部分主要涉及到layout,以及viewgroup测绘的时候,使用的是match_parent宽高时,如何控制子view的显示,有兴趣的朋友可以直接去看代码,这里暂不做赘述了。同时

4 总结

行文至此,我不禁点了根黄鹤楼,望着那袅袅的烟,一抬手摸着了天...
天边飘来一个:
github地址

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

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,945评论 4 60
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,050评论 25 707
  • 今天的晨读《连接,如何应对亲密关系中的焦虑》中,关于亲密关系,我延伸的理解为不只局限于是夫妻、恋人之间的...
    静亦境阅读 244评论 4 4
  • 文/彬睿 今天,早早的吃完早餐,我们全家带上妹妹就兴高采烈的出门了,因为我要和爸爸妈妈一起去决战“峨眉山之巅”。坐...
    茉莉初绽阅读 217评论 0 2
  • 金钱这东西,就好像是个魔咒,有钱的人爱,没钱的人更爱,穷人爱,富人还爱,大人爱,小孩也爱,男人女人老人都爱。不爱不...
    张淑萍阅读 330评论 0 0