安卓开发中矢量图的绘制及动画

前言

矢量图也称为面向对象的图像或绘图图像,是根据几何特性来绘制的图形,在安卓开发中可以使用失量图代替原来的图片资源,矢量图具有占用空间小和可以随意缩放但不失真的优势,在我的多个项目中都有运用。

通过学习和实践,我总结了一些与矢量图相关的知识,方便今后更好的使用矢量图,同时也可以供大家查阅参考。

矢量图的绘制

绘制矢量图之前需要先定义画布的宽高,后续的绘制效果都展示在这个画布上。在绘制过程中需要输入的坐标就是这个画布上的点。

安卓的矢量图常见于drawable文件夹下,是一个xml文件,由vector标签包裹,在vector标签中可包含多个path标签,依次叠加显示。

vector标签的常用属性:
android:width="200dp" - 预览宽度
android:height="200dp" - 预览高度
android:viewportWidth="1024" - 画布宽度(下面path路径中的点位位于画布中)
android:viewportHeight="1024" - 画布高度
android:tint="#FFFFFF" - 矢量图着色(会对矢量图的全部内容进行统一着色,覆盖原有颜色)

path标签的常用属性
android:fillColor="#FFFFFF" - 填充颜色
android:pathData="M82,500H942V524H82V500" - 矢量图路径
android:strokeColor="#FFFFFF" - 边框颜色,默认的边框是透明的
android:strokeWidth="20" - 边框的宽度

在矢量图中最重要的就是path属性,图像的样式就是由path属性中的数据绘制而成,这些数据由不同的命令组合而成,下面就介绍一些矢量图的绘制命令。

  • M 设置画笔的位置,语法:M坐标
    M200,10 - 将画笔移动到200,10的位置。
  • L 从当前位置连接一条直线至指定位置,语法:L坐标
    L300,100 - 从当前位置连接一条直线到300,100位置。
  • H 保持纵坐标不变,画一条横线至目标横坐标位置,语法:H横坐标
    H200 - 画一条横线至横坐标为200的点上。
  • V 保持横坐标不变,画一条竖线至目标纵坐标位置,语法:V纵坐标
    V200 - 画一条竖线至纵坐标为200的点上。
  • Q 连接目标点位并对连线做二次贝塞尔曲线处理,语法:Q贝塞尔坐标,目标点坐标
    Q300,100,400,200 - 连接当前位置和400,200做一条直线,在直线上做贝塞尔曲线变换,变换的锚点为300,100。
  • C 连接目标点位并对连线做三次贝塞尔曲线处理,语法:C贝塞尔坐标1,贝塞尔坐标2,目标点坐标
    C300,250,500,350,400,400 - 连接当前位置和400,400做一条直线,先用贝塞尔坐标1对直线做一次变换,再用贝塞尔坐标2对之前的结果做一次变换,最终会得到一个S型的线段。
  • A 连接起始点与目标点,再根据指定的半径和角度绘制一个圆,使线段的两个端点都在圆的边上,然后根据圆弧参数和绘制方向参数画出所需的圆弧,语法:A横向半径,纵向半径,旋转角度,圆弧大小,绘制方向,目标坐标
    A500,300,0,0,1,0,400 - 绘制圆弧的过程比较复杂,下面分步模拟一下。
    1. 根据所指定的横向半径和纵向半径先绘制一个椭圆,如果两个半径一致就是一个正圆。
    2. 根据指定的旋转角度对刚刚绘制的椭圆进行旋转,所传入的角度与0-360度一一对应,0=没有旋转,90=把椭圆竖过来,360=转了一整圈,720=转了两圈。
    3. 此时一个目标椭圆已经形成,然后需要用起点和终点连成线段去切割这个椭圆,使起点和终点恰好都在椭圆的边上,如果椭圆很小,会将椭圆等比放大至两点位置。
    4. 在上一步中有两种情况,一个是椭圆比较小,放大后两点连线为椭圆的直径,这时圆弧大小的参数就没用了,写1或0都行。如果椭圆比较大,那么符合两点都在椭圆边上情况的位置就有两个,此时如果从起点到终点顺时针去画圆弧就会有一个大圆弧一个小圆弧,如果我们需要大圆弧的情况就把圆弧大小的参数设置为1,反之则设置为0。
    5. 接下来的一个参数是绘制顺序,绘制顺序为1的时候是顺时针绘制,绘制顺序为0的时候是逆时针绘制,一般我们都会先根据图像的需求指定绘制顺序,再根据绘制顺序指定圆弧大小参数。
    6. 最后的一个参数是目标坐标,这个坐标在第三步的时候已经使用了,此时我们需要的圆弧就已经画完了。
  • Z 闭合图形,将当前位置与路径起始点连接起来

将前面的命令示例连接起来就可以生成一个完整的图像,它大概长这个样子:


矢量图绘制命令示例

画布的尺寸为500x500,图上的顶点是200,10的位置,也是我们开始作图的起点。通过这个图片可以更好的理解每一个绘图命令。

  • 以上的绘制命令均有其对应的小写版本,M-m L-l H-h V-v Q-q C-c A-a,小写版本的功能与大写版本一致,但其中的坐标都替换成了相对位置,使用相对位置的好处是起点发生变化后,后面的路径自动跟随起点移动,不需要所有的路径都调整参数。

  • 安卓矢量图的路径会自动闭合,结尾没有加Z命令也和添加了Z命令的效果一致。

矢量图动画

安卓中可以为矢量图添加动画效果,这样用户就可以看到一个动的图片,可以一定程度的提高app的交互效果。矢量图动画是图形内部的变化,可以做到View动画无法实现的效果。

pathData动画

这种动画针对的是矢量图中path字段的值,通过连续改变path字段的值而达到产生动画的效果。

注:pathData动画所需的AnimatedVectorDrawable最低要求API等级为25

实现一个矢量图动画需要以下几步:
1. 准备起始状态和结束状态的矢量图两张。
2. 创建动画配置文件。
3. 创建动画矢量图文件。
4. 启动动画。

  • 首先,我们准备用于动画的两张矢量图,分别代表动画的起始样式和结束样式,而且这两张图执行动画的path必须是同形path。

通过对矢量图绘制的了解,我们知道矢量图就是一个一个的点位进行直线或曲线连接形成的一个图形,所以在做矢量图变化的时候,系统是把我们初始各个点位的坐标通过系统计算后逐渐的改变成了结束时的坐标,这就要求我们做动画的两个矢量图点位的个数是一致的,同时由于系统比较傻,只能是直线转直线,曲线转曲线,所以这两张图的路径命令及顺序也要一致。这就是传说中的同形path

基于这种要求,我准备了两个矢量图:

// icon_filter_off.xml 未启用筛选功能时的样式
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="200dp"
    android:height="200dp"
    android:tint="#000000"
    android:viewportWidth="1024"
    android:viewportHeight="1024">

    <path
        android:fillColor="#000000"
        android:pathData="M102,112L282,382V912Q282,942,322,912L482,792V382L812,100Q832,82,812,82H122Q82,82,102,112" />

    <path
        android:name="status"
        android:fillColor="#000000"
        android:pathData="M582,490L582,530L882,530L882,490ZM582,640L582,680L882,680L882,640ZM582,790L582,830L882,830L882,790Z" />
</vector>
icon_filter_off.xml 展示效果
// icon_filter_on.xml 启用了筛选功能时的样式
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="200dp"
    android:height="200dp"
    android:tint="#000000"
    android:viewportWidth="1024"
    android:viewportHeight="1024">

    <path
        android:fillColor="#000000"
        android:pathData="M102,112L282,382V912Q282,942,322,912L482,792V382L812,100Q832,82,812,82H122Q82,82,102,112" />

    <path
        android:name="status"
        android:fillColor="#000000"
        android:pathData="M592,670L562,700L662,800L692,770ZM662,800L692,830L792,730L762,700ZM762,700L792,730L892,630L862,600Z" />
</vector>

icon_filter_on.xml 展示效果

我的目标是在状态切换时把右下角的小图标做一个动画转换,所以右下角的路径是单独写了一个path,同时把path命名为status。
这两个矢量图是我通过代码敲出来的,也满足了同形path的要求,为了这两个矢量图,我把小学和初中的数学知识又都搬了出来,一通计算,才算出了这些坐标,可惜还是没能达到我的心理预期,只能先凑合用。

  • 接下来开始创建动画配置。
    res\animator文件夹下创建filter_turn_on.xml文件,代码如下:
// filter_turn_on.xml 动画的配置文件
<?xml version="1.0" encoding="utf-8"?>
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:propertyName="pathData"
    android:valueFrom="M582,490L582,530L882,530L882,490ZM582,640L582,680L882,680L882,640ZM582,790L582,830L882,830L882,790Z"
    android:valueTo="M592,670L562,700L662,800L692,770ZM662,800L692,830L792,730L762,700ZM762,700L792,730L892,630L862,600Z"
    android:valueType="pathType" />

控制动画运行的是一个objectAnimator,此处把objectAnimator包裹在一个set中也是可以的,说白了就是执行这个动画文件。
duration用来指定动画的持续时间。
propertyName中的pathData指的就是矢量图中的pathData。
valueFromvalueTo一个是起始路径,一个是结束路径,可以想到,这个动画就是在持续修改pathData,从而达到展示动画的效果。而valueFromvalueTo的值是直接从先前准备的矢量图中复制过来的,所以那个结束状态的矢量图中唯一有用的东西就是pathData属性,没有那个文件也无所谓。
valueType这里必须填写pathType,这是专门用来计算path的类型。

  • 动画创建好了之后就开始创建动画矢量图文件。
    之前我们通过@drawable/icon_filter_off的方式就可以引用矢量图,此时的矢量图是静态的,因为这个矢量图最外层由vector包裹,现在需要一个动态的矢量图,所以我们在res\drawable文件夹下创建animated_filter_on.xml,代码如下:
// animated_filter_on.xml 动画矢量图文件
<?xml version="1.0" encoding="utf-8"?>
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:drawable="@drawable/icon_filter_off">
    <target
        android:name="status"
        android:animation="@animator/filter_turn_on" />
</animated-vector>

此时,文件的最外层由animated-vector包裹,同时需要添加一个drawable参数,这个drawable用于指定动画应用于那个矢量图上,我们是要从未启用状态变成启用状态,所以是在未启用状态开始执行动画,在动画未开始的时候展示的也是未启用状态。此处我们指定为@drawable/icon_filter_off
内部有一个target标签,这个标签可以有多个,分别对应不同的动画,但同一个path只能应用一个动画。
name用于指定要执行动画的path。status正是我们为右下角小图标path设置的名称。
animation用于指定需要执行的动画。此处引用我们刚刚创建的动画资源@animator/filter_turn_on
当我们创建好动画矢量图之后,页面中引用的资源就不再是之前的静态矢量图了,需要把ImageView的图片替换成@drawable/animated_filter_on

  • 准备工作已经就绪,接下来就让我们的矢量图动起来。
    在点击事件中将ImageViewdrawable提取出来,将其转换成android.graphics.drawable.AnimatedVectorDrawable类型,然后调用start()方法即可。
// java
AnimatedVectorDrawable drawable= (AnimatedVectorDrawable) imageViewFilter.getDrawable();
drawable.start();

// kotlin
(imageViewFilter.drawable as AnimatedVectorDrawable).start()

经过这么多的步骤,我们终于做出了一个矢量图动画,而且是一个。说实话,有点累,然而我这个状态切换的动画一套就要两个,所以我又加了一个回来的动画和对应的动画矢量图,一共六个文件,完成了筛选状态的两个切换动画。这还是比较简单的实现方式,对于两种状态切换的动画,网上还有一种使用selector的方式,这种方式更麻烦,而且使用方法并没有简单一些,所以我的选择是在需要切换状态的时候更改ImageView的图片资源,然后再执行动画。

// kotlin
// 这是点击事件中切换状态样式的代码,实际业务中可能会根据业务数据判断是否需要切换。
imageViewFilter.run {
    if (isSelected) {
        setImageResource(R.drawable.animated_filter_off)
        (drawable as AnimatedVectorDrawable).start()
    }else{
        setImageResource(R.drawable.animated_filter_on)
        (drawable as AnimatedVectorDrawable).start()
    }
    isSelected = !isSelected
}

trimPath动画

trimPath动画相当于是改变了矢量图绘制的位置,是从头开始画还是从80%的位置开始画,然后再动态的修改这个百分比,从而达到动画的效果。理解起来倒不是很难。

先放一个我使用trimPath动画做的loading效果,这个动画效果被我用在LoadingDialog中,在界面加载的时候会重复播放这个动画。

loading

为了做到这种效果,我们一共需要以下几个步骤:
1. 设计矢量图,并将其添加到安卓项目中。
2. 根据动画需求,添加动画配置文件。
3. 配置动画矢量图文件,在使用的地方进行引用。
4. 开始执行动画。

  • 首先是设计一个自己的线状动画,我的灵感来自于心电图,一个人活着的话会有一个周期性的心电图波动,而我用这个形状来代表APP正在干活。在学习了矢量图的绘制之后,一个心电图的形状并不难,只是计算坐标比较累。
// icno_loading.xml loading的完全体样式
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:width="200dp"
    android:height="200dp"
    android:tint="#000000"
    android:viewportWidth="1024"
    android:viewportHeight="1024">

    <path
        android:name="load"
        android:pathData="M82,512h280l60,-172l60,430l80,-602l70,430l30,-86h280"
        android:strokeWidth="20"
        android:strokeColor="#000000"
        android:trimPathStart="0"
        android:trimPathEnd="0"
        tools:trimPathEnd="1" />
</vector>

android:name="load"不用多说,这个是我们做动画时路径名称。这里为了让心电图路径更清晰,我设置了描边宽度为20(android:strokeWidth="20"),同时还要设置描边的颜色才能展示出来。后面的android:trimPathStart="0"android:trimPathEnd="0"是本次trimPath动画的重点。

android:trimPathStart="0" - 路径绘制的起始点,范围[0,1]。
android:trimPathEnd="0" - 路径绘制的终点,范围[0,1]。

实际展示的效果就是从起点画到终点的路径。当绘制范围超过[0,1]时也可以画出来,类似于循环绘制。具体效果就自行体会吧。

这两个属性都设置为0是因为动画的起始帧都为0,然后通过objectAnimator慢慢把这两个属性变为1,这样一个慢慢增长的动画就形成了。
网络上一个横线变成搜索按钮的示例是将这两个属性分别应用到了两个path上,而我是将两个属性同时应用到一个path上,原理都是一样的。

  • 接下来为刚刚预留的属性配置动画效果,要做到和心电图差不多的扫描效果,要同时修改路径的起始点和终点,这样可以只展示整个路径中的某一段。而且两个动画要有一定间隔,如果同步执行,起点和终点肯定是一致的,这样什么都画不出来。
// loading.xml loading动画配置
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">

    <objectAnimator
        android:duration="3000"
        android:interpolator="@android:anim/accelerate_decelerate_interpolator"
        android:propertyName="trimPathStart"
        android:repeatCount="infinite"
        android:startOffset="1000"
        android:valueFrom="0"
        android:valueTo="1"
        android:valueType="floatType" />

    <objectAnimator
        android:duration="3000"
        android:interpolator="@android:anim/accelerate_decelerate_interpolator"
        android:propertyName="trimPathEnd"
        android:repeatCount="infinite"
        android:valueFrom="0"
        android:valueTo="1"
        android:valueType="floatType" />
</set>

在配置文件中,我将两个动画都设置为3秒且循环播放,起始点的动画慢于终点的动画1秒,达到只画中间1秒间隔线段的效果。和路径变形动画的区别是android:valueType="floatType",我们只需要计算从0到1的数字,然后应用到trimPathStarttrimPathEnd字段上。至此,loading的动画就配置完了。

  • 所需的资源都准备好了之后就创建一个动画矢量图文件,在这个文件中将静态的矢量图和动画配置绑定到一起。
// animated_loading.xml 用于界面的动画矢量图文件
<?xml version="1.0" encoding="utf-8"?>
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:drawable="@drawable/icon_loading">

    <target
        android:name="load"
        android:animation="@animator/loading" />
</animated-vector>

这一步已经没什么可说的了,就是将指定的矢量图中指定的路径设置一个指定的动画。

  • 最后,将animated_loading设置到目标ImageView中,在合适的时机开始动画。启动动画的方式和路径变换一致,然后就可以欣赏自己制作的动画效果了。

结语

通过几天的学习,已经大致掌握了矢量图的展示及动画的制作,但这一套流程下来成本比较高,是程序员方式的动画制作流程。除了制作成本,创意成本也是相当高的,一个好的创意能极大的提升用户体验,而好多时候我们的创意能够被实现也是很困难的。希望以后能实现一些更好的效果,让用户使用起来更舒服。

参考文章

SVG—最简单的SVG动画
SVG路径(path)中的圆弧(A)指令的语法说明及计算逻辑
Android中的矢量图
Android高级动画(2)

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

推荐阅读更多精彩内容