前言
前面陆陆续续写了几篇 高级 UI 系列文章 ,感觉还不错。因为工作内容原因作者对 UI 开发涉及的很少,所以打算写一点关于 UI 的文章,也算是给自己一个全面的复习。本篇文章还是 基本概念 + 实战来讲解。
概念
SVG 的全称是 (Scalable Vector Graphics) 它是一个可缩放的矢量图形,是专门用于网络的矢量图标准,与矢量图相对应的是位图,Bitmap 就是位图,它由一个个像素点组成,当图片放大到一定大小时, 就会出现马赛克现象,Photoshop 就是常用的位图处理软件,而矢量图则由一个个点组成,经过数学计算利用直线和曲线绘制而成,无论如何放大,都不会出现马赛克问题,illustrator 就是常用的矢量图绘图软件。
SVG VS Bitmap
好处:
SVG 使用 XML 格式定义图形,,可被非常用的多的工具读取和修改;
SVG 由点来存储,由计算机根据点信息绘图,不会失真,无须根据分辨率适配多套图标;
SVG 的占用空间比 Bitmap 小,比如一张 500px * 500px 的图像,转成 SVG 后占用的空间大小是 20KB, 而 PNG 图片则需要 732KB 的空间。
SVG 可以转换 Path 路径,与 Path 动画相结合,可以形成更丰富的动画。
vector 标签
在 Android 中, SVG 矢量图是使用标签定义的,并存放在 res/drawable/ 目录下。一段简单的 SVG 图像代码定义如下:
android:height="24dp"
android:viewportHeight="1024"
android:viewportWidth="1024"
android:width="24dp"
tools:ignore="MissingDefaultResource">
android:pathData="M513.29,738h-2.3V0h2.3z"/>
android:pathData="M512.08,727.97S482.38,896.04 480.09,939.08c-0.76,14.31 -9.58,84.92 32.88,84.92"/>
android:pathData="M511.02,1024c42.47,0 33.66,-70.6 32.89,-84.92 -2.3,-43.04 -31.99,-211.11 -31.99,-211.11"/>
它定义的图像如下所示:
上面水滴形状就是呈现出来的对应的图像,在这段代码中,首先使用 vector 标签来指定这是一幅 SVG 图像,而它有下面几个属性。
width/height : 表示该 SVG 宽高
viewportHeight/viewportWidth: 表示 SVG 图形划分的比例
path 标签
常用属性
标签名称
说明
android:name
声明一个标记,类似于 ID ,便于对其做动画的时候顺利地找到该节点
android:pathData
对 SVG 矢量图的描述
android:strokeWidth
画笔的宽度
android:fillColor
填充颜色
android:fillAlpha
填充颜色的透明度
android:strokeColor
描边颜色
android:strokeWidth
描边宽度
android:strokeAlpha
描边透明度
android:strokeLineJoin
用于指定折线拐角形状,取值有 miter (结合处为锐角)、round(结合处为圆弧)、bevel(结合处为直线)
android:strokeLineCap
画出线条的终点的形状(线帽),取值有 butt(无限帽) 、round (圆形线帽)、square(方形线帽)
android:strokeMiterLimit
设置斜角的上限
android:trimPathStart 属性
该属性用于指定路径从哪里开始,取值 0 ~ 1,表示路径开始位置的百分比。当取值为 0 时,表示从头部开始;当取值为 1 时,整条路径不可见。
android:trimPathEnd 属性
该属性用于指定路径的结束位置,取值为 0 ~ 1 ,表示路径结束位置的百分比。当取值为 1 时,路径正常结束;当取值为 0 时,表示从头开始位置就已经结束了,整条路径不可见。
android:trimPathOffset 属性
该属性用于指定结果路径的位移距离,取值为 0 ~ 1 。当取值为 0 时,不进行位移;当取值为 1 时,位移整条路径的长度。
android:pathData 属性
在 path 标签中,主要通过 pathData 属性来指定 SVG 图像的显示内容。而 pathData 属性初 M 和 L 指令以外,还有更多的指定。
指令
对应
说明
M
moveto(M x,y)
将画笔移动到指定的地方
L
lineto(L X,Y)
画直线到指定的坐标位置
H
Horizontal lineto(H X)
画水平线到指定的 X 坐标位置
V
Vertical lineto(V Y)
画垂直线到指定的 Y 坐标位置
C
curveto(C X1,Y1,X2,Y2,ENDX,ENDY)
三阶贝济埃曲线
S
Smooth curveto(S X2,Y2,ENDX,ENDY)
三阶贝济埃曲线
Q
Quadratic Belzier curve(Q X,Y,ENDX,ENDY)
二阶贝济埃曲线
T
smooth quadratic Belaizer curveto(T ENDX,ENDY)
映射前面路径后的终点
A
elliptic Arc(A RX,RY,XROTATION,FLAYG1,FLAY2,X,Y)
弧线
Z
Closepath
关闭路径
制作 SVG 图像
方法一: 设计软件
如有你有绘图基础,则可以使用 Illustrator 或在线 SVG 工具制作 SVG 图像,比如:editor.method.ac/ ,或通过 SVG 源文件下载网站下载后进行编辑。
方法二: Iconfont
Android 中引入 SVG 图像
准备工作
我们知道在 Android 中是不支持直接使用 SVG 图像解析的,我们必须将 SVG图像转换为 vector 标签描述,这里有 2 种方法;
方法一: 在线转换
方法二: AS 转
按照我上面的步骤,就可以生成 Vector 图像了
基础使用
下面对 ImageView 怎么直接使用 vector 进行说明(ps:这里用的 androidx 版本,如果是低版本需要自己去做兼容);
在 ImageView 中使用
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" tools:ignore="MissingDefaultResource">
android:id="@+id/iv"
android:layout_centerInParent="true"
android:layout_width="match_parent"
android:src="@drawable/ic_line"
android:layout_height="500dp"/>
进阶使用
前面讲解了 vector 标签,静态显示 vector 和制作 SVG 图像的方法,那么该小节就讲解动态的 vector, 动态的 vector 所实现的效果才是 SVG 图像在 Android 应用中的精髓。
要实现 Vector 动画,首先需要 Vector 图像和它所对应的动画,这里依然使用上一小节水滴状态的图像,
先来看一下效果:
给 path 定义 name,如下所示
定义一个 Animator 文件,以表示对这幅 Vector 图像做动画
android:propertyName="trimPathStart"
android:valueFrom="1"
android:valueTo="0"
android:duration="3000"
>
需要注意的是,这里的文件是对应 Vector 中 path 标签的,这里动画效果是动态改变 path 标签的 trimPathStart 属性值,从 0 ~ 1 。
定义 animated-vector 进行关联
xmlns:tools="http://schemas.android.com/tools" android:drawable="@drawable/ic_line"
tools:targetApi="lollipop">
android:name="num_1">
android:name="num_2">
android:name="num_3">
在上述代码中,drawable 代表关联的 vector 图像,target 代表将 path name 和动画进行关联
代码中进行设置
class SVGDemo1Activity : AppCompatActivity() {
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_svg)
startAnimatabe()
}
private fun startAnimatabe() {
val animatedVectorDrawable = AnimatedVectorDrawableCompat.create(this, R.drawable.line_animated_vector)
iv.setImageDrawable(animatedVectorDrawable)
val animatable = iv.drawable as Animatable
animatable.start()
}
}
实战
输入搜索动画
可以自己随意捣鼓绘制,绘制好了之后点击视图->源代码,将 SVG 代码复制出来保存成 search_svg.xml
点击空白或者直接将 SVG 拖拽指定区域进行转换
将转换好的 Android 格式的 vector 导入 AS
开始制作动画关联
//1.在 /res/aniamator 文件夹下 定义动画
android:propertyName="trimPathStart"
android:valueFrom="1"
android:valueTo="0"
android:duration="2000"
>
//2\. 在/res/drawable/ 定义 vector
android:width="580dp"
android:height="400dp"
android:viewportWidth="580"
android:viewportHeight="400">
android:name="svg_1"
android:strokeColor="#000"
android:strokeWidth="1.5"
android:pathData="M 164.54545 211.91761 L 380 212.8267" />
android:name="svg_2"
android:strokeColor="#000"
android:strokeWidth="1.5"
android:pathData="M 360 180.09943 C 366.024924042 180.09943 370.90909 184.780091469 370.90909 190.55398 C 370.90909 196.327868531 366.024924042 201.00853 360 201.00853 C 353.975075958 201.00853 349.09091 196.327868531 349.09091 190.55398 C 349.09091 184.780091469 353.975075958 180.09943 360 180.09943 Z" />
android:name="svg_3"
android:strokeColor="#000"
android:strokeWidth="1.5"
android:pathData="M 369.09091 197.37216 L 380.90909 208.28125" />
//3\. 在/res/drawable/ 关联动画和 vector
xmlns:tools="http://schemas.android.com/tools"
android:drawable="@drawable/search_svg"
tools:targetApi="lollipop">
android:name="svg_1">
android:name="svg_2">
android:name="svg_3">
效果
警车灯闪烁
今日头条下拉刷新动画
来一个复杂组合动画,请看下面效果图:
准备 vector 数据
android:width="200dp"
android:height="200dp"
android:viewportHeight="200"
android:viewportWidth="200">
android:name="tt_1"
android:fillColor="#C2BFBF"
android:pathData="
M20,30
L100,30
M100,30
L100,90
M100,90
L20,90
M20,90
L20,30"
android:strokeColor="#C2BFBF"
android:strokeLineCap="round"
android:strokeWidth="6"/>
android:name="tt_2"
android:pathData="
M120,30
L180,30
M120,60
L180,60
M120,90
L180,90"
android:strokeColor="#C2BFBF"
android:strokeLineCap="round"
android:strokeWidth="6"/>
android:name="tt_3"
android:pathData="
M20,120
L180,120
M20,150
L180,150
M20,180
L180,180"
android:strokeColor="#C2BFBF"
android:strokeLineCap="round"
android:strokeWidth="6"/>
android:pathData="
M0,0
L200,0
M200,0
L200,200
M200,200
L0,200
M0,200
L0,0"
android:strokeColor="#C2BFBF"
android:strokeLineCap="round"
android:strokeWidth="6"/>
定义顺时针执行动画并做 pathData 变换
这里拿其中一个位置变化来举例说明:
android:ordering="sequentially">//按顺序执行
//依次执行 pathData 位置变换
android:duration="600"
android:interpolator="@android:interpolator/decelerate_cubic"
android:propertyName="pathData"
android:valueFrom="
M20,30
L100,30
M100,30
L100,90
M100,90
L20,90
M20,90
L20,30"
android:valueTo="
M100,30
L180,30
M180,30
L180,90
M180,90
L100,90
M100,90
L100,30"
android:valueType="pathType" />
android:duration="600"
android:interpolator="@android:interpolator/decelerate_cubic"
android:propertyName="pathData"
android:valueFrom="
M100,30
L180,30
M180,30
L180,90
M180,90
L100,90
M100,90
L100,30"
android:valueTo="
M100,120
L180,120
M180,120
L180,180
M180,180
L100,180
M100,180
L100,120"
android:valueType="pathType" />
android:duration="600"
android:interpolator="@android:interpolator/decelerate_cubic"
android:propertyName="pathData"
android:valueFrom="
M100,120
L180,120
M180,120
L180,180
M180,180
L100,180
M100,180
L100,120"
android:valueTo="
M20,120
L100,120
M100,120
L100,180
M100,180
L20,180
M20,180
L20,120"
android:valueType="pathType" />
android:duration="600"
android:interpolator="@android:interpolator/decelerate_cubic"
android:propertyName="pathData"
android:valueFrom="
M20,120
L100,120
M100,120
L100,180
M100,180
L20,180
M20,180
L20,120"
android:valueTo="
M20,30
L100,30
M100,30
L100,90
M100,90
L20,90
M20,90
L20,30"
android:valueType="pathType" />
如果对标签中的定义还不了解的先去看下文章中 path 标签 中的说明。如果不理解标签意思,根本就看不懂。
进行关联
xmlns:tools="http://schemas.android.com/tools"
android:drawable="@drawable/ic_toutiao"
tools:targetApi="lollipop">
android:animation="@animator/tt_path_one"
android:name="tt_1"/>
android:animation="@animator/tt_path_two"
android:name="tt_2"/>
android:animation="@animator/tt_path_three"
android:name="tt_3"/>
代码控制重复执行
class SVGDemo1Activity : AppCompatActivity() {
var reStartTT = @SuppressLint("HandlerLeak")
object : Handler() {
override fun handleMessage(msg: Message) {
super.handleMessage(msg)
startAnimatabe(R.drawable.line_animated_toutiao, true)
}
}
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_svg)
//水滴动画
startWaterDropAnimator.setOnClickListener {
startAnimatabe(R.drawable.line_animated_vector, false)
}
//搜索动画
startSearchAnimator.setOnClickListener {
startAnimatabe(R.drawable.line_animated_search, false)
}
//执行警车动画
startPoliceCarAnimator.setOnClickListener {
startAnimatabe(R.drawable.line_animated_car, false)
}
//执行头条动画
startTTAnimator.setOnClickListener {
startAnimatabe(R.drawable.line_animated_toutiao, true)
}
}
private fun startAnimatabe(lineAnimatedVector: Int, isRegister: Boolean): Animatable {
val animatedVectorDrawable = AnimatedVectorDrawableCompat.create(this, lineAnimatedVector)
iv.setImageDrawable(animatedVectorDrawable)
val animatable = iv.drawable as Animatable
animatable.start()
animatedVectorDrawable!!.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() {
override fun onAnimationEnd(drawable: Drawable?) {
super.onAnimationEnd(drawable)
if (!isRegister) return
animatedVectorDrawable.unregisterAnimationCallback(this)
//重新开始在 xml 设置 restart 无效暂时用 Handler 实现了。
reStartTT.sendEmptyMessage(0)
}
})
return animatable
}
}
绘制中国地图
该篇之前实现 SVG pathData 都是利用 ImageView 来实现,并不是所有的场合都适合上面的方式,比如我想要实现 pathData 区域点击,那么上面所讲的方式应该是不能实现,下面我们以一个实例来看怎么自定义 View 实现 PathData 和 pathData 区域点击事件。
下面我们利用 path 来绘制一个中国地图,先来看一个最终效果图,如下:
看起来是不是很炫,还不错,嘿嘿,下面我们就来看一下如何实现。
准备地图 SVG
首先去下载地图数据
选择下载免费的地图数据
* 找到对应的国家点击下载 svg 数据
* 选择对应的地图数据,我这里下载的是高质量的 SVG
转好之后放入 AS 中,如下所示
现在有了这些数据,我们就可以解析 xml `path` 节点,拿到 `pathData` 数据我们不就可以绘制 path 了嘛。下面就开始解析 xml ,解析的方法很多种,我们这里用 dom 解析。
开始解析 xml
解析 xml 有很多种方式,这里就直接使用 DOM 解析,pathData2Path 我这里直接用 Android SDK 提供的 android.support.v4.graphics#PathParser 由于源码中它被标注了 hide 属性 ,我们需要直接将它 copy 到我们自己项目中, 具体转化请看如下代码:
/**
* 开始解析 xml
*/
public fun dom2xml(stream: InputStream?): MutableList {
mapDataLists.clear()
//dom
val newInstance = DocumentBuilderFactory.newInstance()
val newDocumentBuilder = newInstance.newDocumentBuilder()
//拿到 Docment 对象
val document = newDocumentBuilder.parse(stream)
//获取 xml 中属于 path 节点的所有信息
val elementsByTagName = document.getElementsByTagName(PATH_TAG)
//定义四个点,确定整个 map 的范围
var left = -1f
var right = -1f
var top = -1f
var bottom = -1f
//开始遍历标签,拿到 path 数据组
for (pathData in 0 until elementsByTagName.length) {
val item = elementsByTagName.item(pathData) as Element
val name = item.getAttribute("android:name")
val fillColor = item.getAttribute("android:fillColor")
val strokeColor = item.getAttribute("android:strokeColor")
val strokeWidth = item.getAttribute("android:strokeWidth")
val pathData = item.getAttribute("android:pathData")
val path = PathParser.createPathFromPathData(pathData)
mapDataLists.add(MapData(name, fillColor, strokeColor, strokeWidth, path))
//获取控件的宽高
val rect = RectF()
//获取到每个省份的边界
path.computeBounds(rect, true)
//遍历取出每个path中的left取所有的最小值
left = if (left == -1f) rect.left else Math.min(left, rect.left)
//遍历取出每个path中的right取所有的最大值
right = if (right == -1f) rect.right else Math.max(right, rect.right)
//遍历取出每个path中的top取所有的最小值
top = if (top == -1f) rect.top else Math.min(top, rect.top)
//遍历取出每个path中的bottom取所有的最大值
bottom = if (bottom == -1f) rect.bottom else Math.max(bottom, rect.bottom)
}
//MAP 的矩形区域
MAP_RECTF = RectF(left, top, right, bottom)
return mapDataLists;
}
复制代码
进行控件测量适配横竖屏切换和宽高定义 wrap_content 模式
/**
* 开始测量
*/
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//测量模式
var widthMode = MeasureSpec.getMode(widthMeasureSpec)
var heightMode = MeasureSpec.getMode(heightMeasureSpec)
//测量大小
widthSize = MeasureSpec.getSize(widthMeasureSpec)
heightSize = MeasureSpec.getSize(heightMeasureSpec)
if (!MAP_RECTF.isEmpty && mMapRectHeight != 0f && mMapRectWidth != 0f) {
//显示比例
scaleHeightValues = heightSize / mMapRectHeight
scaleWidthValues = widthSize / mMapRectWidth
}
//xml 文件中宽高 wrap_content
if (widthMode == MeasureSpec.AT_MOST || heightMode == MeasureSpec.AT_MOST) {
//如果是横屏宽保留最大,高需要适配
if (widthSize < heightSize && mMapRectHeight != 0f) {
setMeasuredDimension(widthSize, (mMapRectHeight * scaleWidthValues).toInt())
} else {
setMeasuredDimension(widthSize, heightSize)
}
} else {
setMeasuredDimension(widthSize, heightSize)
}
}
开始绘制 path
/**
* 绘制 Map 数据
*/
@SuppressLint("Range")
private fun drawMap(canvas: Canvas) {
canvas.save()
if (widthSize > heightSize) {
canvas.scale(scaleWidthValues, scaleHeightValues)
} else {
canvas.scale(scaleWidthValues, scaleWidthValues)
}
mapDataList.forEach { data ->
run {
if (data.isSelect) {
drawPath(data, canvas, Color.RED)
} else {
drawPath(data, canvas, Color.parseColor(data.fillColor))
}
}
}
canvas.restore()
canvas.drawText("中国🇨🇳地图", widthSize / 2 - mPaintTextTitle.measureText("中国🇨🇳地图") / 2f, 100f, mPaintTextTitle)
}
/**
* 开始绘制 Path
*/
private fun drawPath(
data: MapData,
canvas: Canvas,
magenta: Int
) {
mPaintPath.setColor(magenta)
mPaintPath.setStyle(Paint.Style.FILL)
mPaintPath.setTextSize(30f)
mPaintPath.setStrokeWidth(data.strokeWidth.toFloat())
canvas.drawPath(data.pathData, mPaintPath)
val rectF = RectF()
data.pathData.computeBounds(rectF, true)
canvas.drawText(
if (data.name.isEmpty()) "" else data.name,
rectF.centerX() - mPaintText.measureText(data.name) / 2,
rectF.centerY(), mPaintText
)
}
给地图添加各自的点击事件
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> return true
MotionEvent.ACTION_UP -> {
handlerTouch(event.getX(), event.getY())
}
}
return super.onTouchEvent(event)
}
/**
* 处理点击事件
*/
private fun handlerTouch(x: Float, y: Float) {
if (mapDataList.size == 0) return
var xScale = 0f
var yScale = 0f
if (widthSize > heightSize) {
xScale = scaleWidthValues
yScale = scaleHeightValues
} else {
xScale = scaleWidthValues
yScale = scaleWidthValues
}
mapDataList.forEach { data ->
run {
data.isSelect = false
if (isTouchRegion(x / xScale, y / yScale, data.pathData)) {
data.isSelect = true
postInvalidate()
}
}
}
}
}
/**
* 判断是否在点击区域内
*/
fun isTouchRegion(x: Float, y: Float, path: Path): Boolean {
//创建一个矩形
val rectF = RectF()
//获取到当前省份的矩形边界
path.computeBounds(rectF, true)
//创建一个区域对象
val region = Region()
//将path对象放入到Region区域对象中
region.setPath(path, Region(rectF.left.toInt(), rectF.top.toInt(), rectF.right.toInt(), rectF.bottom.toInt()))
//返回是否这个区域包含传进来的坐标
return region.contains(x.toInt(), y.toInt())
}
到这里 SVG 知识已经讲解完了,觉得还不过瘾的可以自己尝试一下其他国家的地图绘制。
总结
这里一定要注意在低版本上使用 SVG 存在兼容问题,需要各自查阅资料解决。
不知道还有没有记得上一篇 [高级 UI 成长之路 (六) PathMeasure 制作路径动画] 中我提到了只要给我一个 Path 数据,我就能绘制出图形,看完该篇是不是认为说的没毛病吧。建议大家在项目上多使用 SVG ,好处文章开头也提到了,这里就不在啰嗦了。到这里 SVG 制作图像和动画效果就全部讲完了。