Android自定义View——啥是佩奇?

《啥是佩奇》可谓是年前的一大热点,所谓热点就是你在干嘛,它都能进入你的视线。刚好有看到用Python画佩奇的,所以就寻思着用Android也画了一个。


佩奇完工已有些时日,一直想写篇文章记录下,奈何拖到现在。限于水平有限,不对的地方,还望斧正。

直接点,咱们先来看一下效果,然后再去想怎么画出来。

简单分析下佩奇,会发现构图基本由曲线构成的,还有部分使用了圆、椭圆、矩形等常规图形。
常规图形我们使用Canvas绘制,曲线部分我们使用Path绘制贝塞尔曲线。

所以这篇文章分为三个模块:分别介绍Canvas的使用、Path基础、Path绘制贝塞尔曲线。

一、Canvas回顾

Canvas的使用相对基础一点,我们来一起通过API回顾下:

类别 API 描述
绘制图形 drawPoint, drawPoints, drawLine, drawLines, drawRect, drawRoundRect, drawOval, drawCircle, drawArc 依次为绘制点、直线、矩形、圆角矩形、椭圆、圆、扇形
绘制文本 drawText, drawPosText, drawTextOnPath 依次为绘制文字、指定每个字符位置绘制文字、根据路径绘制文字
画布变换 translate, scale, rotate, skew 依次为平移、缩放、旋转、倾斜(错切)
画布裁剪 clipPath, clipRect, clipRegion 依次为按路径、按矩形、按区域对画布进行裁剪
画布状态 save,restore 保存当前画布状态,恢复之前保存的画布

具体到每个API就不展开说明了,如有需要可以查看末尾的参考文章,都有很详细的介绍,这里我们画个鼻子做示例:


图片有些卡顿,流程还是容易知晓的:

  • 绘制一个倾斜的椭圆,进度变化,闭环时上色
  • 绘制两个小圆环,进度变化,闭环时上色
  • 为了绘制方便,椭圆和小圆是同时绘制的

分析完成那就开撸吧,具体数值都是yy得来的,我们主要看一下流程步骤和api的使用:

private RectF rect;
private Paint paintPink;
private Paint paintRed;

public void init() {
    // 初始化矩形,各个部位的父容器,如鼻子是在矩形内部画椭圆
    rect = new RectF();
    // 创建画笔
    paintPink = new Paint();
    // 设置画笔的颜色
    paintPink.setColor(Color.rgb(255, 155, 192));
    // 设置画笔的填充方式:描边
    paintPink.setStyle(Paint.Style.STROKE);
    // 设置画笔的宽度
    paintPink.setStrokeWidth(3f);
    // 设置抗锯齿,可以圆润一些
    paintPink.setAntiAlias(true);
    ...
    // 其他颜色画笔类似操作...
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    // 鼻子:倾斜的椭圆
    rect.set(dp2px(200), dp2px(101), dp2px(250), dp2px(160));
    // 旋转画布,结束还需旋转回去(在这里实现倾斜)
    canvas.rotate(-15, dp2px(getContext(), 225), dp2px(getContext(), 150));
    if (progressNose < 100) {
        // 如果进度不完整,只进行描边操作
        paintPink.setStyle(Paint.Style.STROKE);
        paintRed.setStyle(Paint.Style.STROKE);
    } else {
        // 如果进度完整,即环形绘制完成,设置画笔为填充模式,设置填充及描边(FILL_AND_STROKE)也行
        paintPink.setStyle(Paint.Style.FILL);
        paintRed.setStyle(Paint.Style.FILL);
    }
    // 画扇形:如果角度为360度,就是矩形的内切椭圆,如果矩形为正方形,则椭圆为正圆
    canvas.drawArc(rect, 0, progressNose * 3.6f, true, paintPink);
    canvas.rotate(15, dp2px(getContext(), 225), dp2px(getContext(), 130));
    // 鼻孔
    // 重新设置矩形的参数为正方形
    rect.set(dp2px(213), dp2px(125), dp2px(223), dp2px(135));
    // 根据进度画圆形鼻孔
    canvas.drawArc(rect, 0, progressNose * 3.6f, false, paintRed);
    rect.set(dp2px(230), dp2px(122), dp2px(240), dp2px(132));
    canvas.drawArc(rect, 0, progressNose * 3.6f, false, paintRed);
}

细节注释都有说明,至于绘制进度百分比,我们这里使用的 ValueAnimator类。

private int progressNose = 0;
private ValueAnimator animNose;

private void initIntAnim() {
    // 设置动画的起始值,也就是我们需要的进度变化区间
    animNose = ValueAnimator.ofint(0, 100);
    animNose.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            // 监听动画进度变化,并执行重绘操作
            progressNose = (int) animation.getAnimatedValue();
            invalidate();
        }
    });
    // 设置动画时长
    animNose.setDuration(3000);
}

当只要执行这一个动画的时候,直接调用 animNose.start() 就可以了。

二、Path初识

The Path class encapsulates compound (multiple contour) geometric paths consisting of straight line segments, quadratic curves, and cubic curves. It can be drawn with canvas.drawPath(path, paint), either filled or stroked (based on the paint's Style), or it can be used for clipping or to draw text on a path.

简单说就是:Path可以通过直线、二次、三次贝塞尔曲线,以及填充描边模式,做出各种炫酷效果。
Path官方文档直通车

我们还是来一起看一下API:

Path 点、线操作 描述
lineTo、rLineTo 绘制线(lineTo的坐标点是相对于原点的,rLineTo的坐标点是相对于上个坐标的偏移量。)
moveTo、rMoveTo 设置下一次操作的起点位置
setLastPoint 改变上一次操作结束点的位置
close 闭合Path(如果连接Path起点和终点能形成一个闭合图形,则会将起点和终点连接起来形成一个闭合图形。)
Path 常规图像 描述
addRect 绘制矩形
addRoundRect 绘制圆角矩形
addCircle 绘制圆形
addOval 绘制椭圆
Path 设置方法 描述
set 将新的path赋值到已有的path
reset 将path的所有操作都清空
offset 将path进行平移
Path 其他属性 描述
isConvex 判断path是否为凸多边形(API >= 21)
isEmpty 判断path中是否包含内容
isRect 判断path是否是矩形

这里依旧没有展开来说,看着比较干瘪无趣,但应该会对Path的能力有所了解。
至于详细使用,可以查看精彩的参考文章:Path从懵逼到精通(1)——基本操作

三、Path和贝塞尔曲线

3.1 我们先来假装了解一下贝塞尔曲线:

以下内容摘抄自维基百科:贝塞尔曲线——维基百科

在数学的数值分析领域中,贝塞尔曲线(英语:Bézier curve,亦作“贝塞尔”)是计算机图形学中相当重要的参数曲线。更高维度的广泛化贝塞尔曲线就称作贝兹曲面,其中贝兹三角是一种特殊的实例。

贝塞尔曲线于1962年,由法国工程师皮埃尔·贝兹(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。贝塞尔曲线最初由保尔·德·卡斯特里奥于1959年运用德卡斯特里奥算法开发,以稳定数值的方法求出贝塞尔曲线。

一阶贝塞尔曲线(线性曲线)

对于一阶贝赛尔曲线,我们可以理解为在起点和终点形成的这条直线上,匀速移动的点。

二阶贝塞尔曲线


为建构二次贝塞尔曲线,可以中介点Q0和Q1作为由0至1的t:

  • 由P0至P1的连续点Q0,描述一条线性贝塞尔曲线。
  • 由P1至P2的连续点Q1,描述一条线性贝塞尔曲线。
  • 由Q0至Q1的连续点B(t),描述一条二次贝塞尔曲线。

也可以说是:P0Q0 : P0P1 = P1Q1 : P1P2 = Q0B : Q0Q1 = t

三阶贝塞尔曲线


对于三次曲线,可由线性贝塞尔曲线描述的中介点Q0、Q1、Q2,和由二次曲线描述的点R0、R1所建构。

高阶贝塞尔曲线

建构更高阶曲线,只是需要更多的中介点,通用公式如下:

可以自己试试效果:贝塞尔曲线演示

3.2 Path绘制贝塞尔曲线

Android是支持贝塞尔曲线的,但是只支持到三阶,我们来一起了解一下:

类别 API 描述
一阶 lineTo、rLineTo 就是绘制线
二阶 quadTo、rQuadTo quadTo(x1, y1, x2, y2)
(x1,y1) 为控制点,(x2,y2)为结束点
三阶 cubicTo、rCubicTo cubicTo(x1, y1, x2, y2, x3, y3)
(x1,y1) 为控制点,(x2,y2)为控制点,(x3,y3) 为结束点;即与二阶区别多一个控制点

这里我们来画类似于口哨这么个玩意:

这里我们是分为两部分进行绘制的,第一部分到拐点那里,是个三阶曲线;第二部分是个二阶曲线。第一部分的终点就是第二部分的起点。

起点、终点、拐点坐标都相对好确定,可是这些控制点的坐标怎么确定呐?
实际项目中我们可以请设计帮忙,通过ps给个大致估算,这里只是跟着感觉试出来的,所以也就是个大致轮廓,不要介意。
代码比较简单,就是坐标点的信息有点多:

private Path mPath = new Path();

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    // 设置起始点
    mPath.moveTo(dp2px(220),dp2px(102));
    // 三阶:头部轮廓(画到鼻子和嘴的连接处)
    mPath.cubicTo(dp2px(-100), dp2px(80), dp2px(130), dp2px(330), dp2px(170), dp2px(170));
    // 二阶:画鼻子的下面的那条线
    mPath.quadTo(dp2px(210), dp2px(170), dp2px(240), dp2px(155));
    canvas.drawPath(mPath, paintPink);
}

静态绘制这样就完工了,可是我们怎么才能实现动态绘制呐?

3.3 动态绘制贝塞尔曲线(TypeEvaluator估值器的使用)

为了获取实时的坐标,我们需要通过 TypeEvaluator 打造一个属于我们自己的估值器。
首先我们需要创建一个自己的类,用于记录各种点信息和具体操作信息:

public class ViewPoint {

    float x, y;
    float x1, y1;
    float x2, y2;
    int operation;

    public ViewPoint() {}

    public ViewPoint(float x, float y) {
        this.x = x;
        this.y = y;
    }

    public static ViewPoint moveTo(float x, float y, int operation) {
        return new ViewPoint(x, y, operation);
    }

    public static ViewPoint lineTo(float x, float y, int operation) {
        return new ViewPoint(x, y, operation);
    }

    public static ViewPoint curveTo(float x, float y, float x1, float y1, float x2, float y2, int operation) {
        return new ViewPoint(x, y, x1, y1, x2, y2, operation);
    }

    public static ViewPoint quadTo(float x, float y, float x1, float y1, int operation) {
        return new ViewPoint(x, y, x1, y1, operation);
    }

    private ViewPoint(float x, float y, int operation) {
        this.x = x;
        this.y = y;
        this.operation = operation;
    }

    public ViewPoint(float x, float y, float x1, float y1, int operation) {
        this.x = x;
        this.y = y;
        this.x1 = x1;
        this.y1 = y1;
        this.operation = operation;
    }

    public ViewPoint(float x, float y, float x1, float y1, float x2, float y2, int operation) {
        this.x = x;
        this.y = y;
        this.x1 = x1;
        this.y1 = y1;
        this.x2 = x2;
        this.y2 = y2;
        this.operation = operation;
    }
}

再写一个类用来记录Path的相关操作,并且记录到具体的ViewPoint中:

public class ViewPath {

    public static final int MOVE = 0;
    public static final int LINE = 1;
    public static final int QUAD = 2;
    public static final int CURVE = 3;

    private ArrayList<ViewPoint> mPoints;

    public ViewPath() {
        mPoints = new ArrayList<>();
    }

    public void moveTo(float x, float y) {
        mPoints.add(ViewPoint.moveTo(x, y, MOVE));
    }

    public void lineTo(float x, float y) {
        mPoints.add(ViewPoint.lineTo(x, y, LINE));
    }

    public void curveTo(float x, float y, float x1, float y1, float x2, float y2) {
        mPoints.add(ViewPoint.curveTo(x, y, x1, y1, x2, y2, CURVE));
    }

    public void quadTo(float x, float y, float x1, float y1) {
        mPoints.add(ViewPoint.quadTo(x, y, x1, y1, QUAD));
    }

    public Collection<ViewPoint> getPoints() {
        return mPoints;
    }
}

最后就是我们的关键点,通过实现TypeEvaluator接口,来打造我们自己的估值器,最终返回实时的坐标。
我们通过泛型传入ViewPoint类,并且复写evaluate()方法。
evaluate() 方法共有三个参数,分别是:当前进度、起始数据和终点数据。
具体计算过程则是分清每一种操作类别,然后套计算公式即可。

public class ViewPathEvaluator implements TypeEvaluator<ViewPoint> {

    public ViewPathEvaluator() {}

    @Override
    public ViewPoint evaluate(float t, ViewPoint startValue, ViewPoint endValue) {

        float x, y;
        float startX, startY;

        // 判断结束点的类型,根据后一个点类型,来计算开始点和结束点的变化
        if (endValue.operation == ViewPath.LINE) {
            // line:画直线,当前值 = 起始点 + t * (结束点-起始点)

            // 判断开始点的类型,找到它真正的起始点
            // 如果上一步操作(startValue)是二阶,取二阶的结束点x1 为新起点
            startX = (startValue.operation == ViewPath.QUAD) ? startValue.x1 : startValue.x;
            // 如果上一步操作(startValue)是三阶,取三阶的结束点x2 为新起点
            startX = (startValue.operation == ViewPath.CURVE) ? startValue.x2 : startX;
            // 以上两步:如果既不是二阶,也不是三阶,直接取startValue.x(一阶)

            // Y 取值方式与 X 类似
            startY = (startValue.operation == ViewPath.QUAD) ? startValue.y1 : startValue.y;
            startY = (startValue.operation == ViewPath.CURVE) ? startValue.y2 : startY;

            x = startX + t * (endValue.x - startX);
            y = startY + t * (endValue.y - startY);

        } else if (endValue.operation == ViewPath.CURVE) {
            // curve:三阶,同上:先求真实起始点,然后套公式求值
            startX = (startValue.operation == ViewPath.QUAD) ? startValue.x1 : startValue.x;
            startY = (startValue.operation == ViewPath.QUAD) ? startValue.y1 : startValue.y;

            startX = (startValue.operation == ViewPath.CURVE) ? startValue.x2 : startX;
            startY = (startValue.operation == ViewPath.CURVE) ? startValue.y2 : startY;

            float oneMinusT = 1 - t;

            //三阶贝塞尔函数(套公式)
            x = oneMinusT * oneMinusT * oneMinusT * startX +
                    3 * oneMinusT * oneMinusT * t * endValue.x +
                    3 * oneMinusT * t * t * endValue.x1 +
                    t * t * t * endValue.x2;

            y = oneMinusT * oneMinusT * oneMinusT * startY +
                    3 * oneMinusT * oneMinusT * t * endValue.y +
                    3 * oneMinusT * t * t * endValue.y1 +
                    t * t * t * endValue.y2;

        } else if (endValue.operation == ViewPath.MOVE) {
            // move:重新设置起点
            x = endValue.x;
            y = endValue.y;

        } else if (endValue.operation == ViewPath.QUAD) {
            startX = (startValue.operation == ViewPath.CURVE) ? startValue.x2 : startValue.x;
            startY = (startValue.operation == ViewPath.CURVE) ? startValue.y2 : startValue.y;

            startX = (startValue.operation == ViewPath.QUAD) ? startValue.x1 : startX;
            startY = (startValue.operation == ViewPath.QUAD) ? startValue.y1 : startY;

            //二阶贝塞尔函数
            float oneMinusT = 1 - t;
            x = oneMinusT * oneMinusT * startX +
                    2 * oneMinusT * t * endValue.x +
                    t * t * endValue.x1;

            y = oneMinusT * oneMinusT * startY +
                    2 * oneMinusT * t * endValue.y +
                    t * t * endValue.y1;

        } else {
            x = endValue.x;
            y = endValue.y;
        }
        return new ViewPoint(x, y);
    }
}

具体使用如下:

private ValueAnimator animHead;
private ViewPoint pointHead = new ViewPoint();
private Path mPath = new Path();

public void initPath() {
    // 千万不要觉得下面很复杂,就是找贝尔塞的控制点和结束点而已,很简单
    // 我们的ViewPath,其实可以绘制任何直线路径和贝塞尔曲线路径了,自己在调用lineTo传入点等就行了
    ViewPath viewPath = new ViewPath();
    pointHead.x = dp2px(220);
    pointHead.y = dp2px(102);
    mPath.moveTo(pointHead.x, pointHead.y);
    viewPath.moveTo(pointHead.x, pointHead.y);
    viewPath.curveTo(dp2px(-100), dp2px(80), dp2px(130), dp2px(330), dp2px(170), dp2px(170));
    viewPath.quadTo(dp2px(210), dp2px(170), dp2px(240), dp2px(155));
    animHead = ValueAnimator.ofObject(new ViewPathEvaluator(), viewPath.getPoints().toArray());
    animHead.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator valueAnimator) {
            // 获取最新的坐标点,并执行重绘操作
            pointHead = (ViewPoint) valueAnimator.getAnimatedValue();
            invalidate();
        }
    }
    );
    animHead.setDuration(5000);
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    mPath.lineTo(pointHead.x, pointHead.y);
    canvas.drawPath(mPath, paintPink);
}

执行下start()操作,就是一个动态绘制的猪头了:


至于让所有的动画连起来绘制,则使用AnimatorSet类,playSequentially()方法是将动画集合按顺序播放。

完整代码请移步GitHub:https://github.com/princekin-f/Page

参考文章:
自定义 View——Canvas 与 ValueAnimator – Idtk
Path从懵逼到精通(1)——基本操作
Path从懵逼到精通(2)——贝塞尔曲线
Android 属性动画-绘制贝塞尔曲线路径
安卓自定义View进阶 - 贝塞尔曲线
自定义控件三部曲之绘图篇(六)——Path之贝赛尔曲线和手势轨迹、水波纹效果
浅析Android动画(三),自定义Interpolator与TypeEvaluator

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

推荐阅读更多精彩内容