Android 自定义 View
绘制基础
自定义绘制概述
- 自定义绘制的方式是重写绘制方法,其中最常用的是
onDraw()
- 绘制的关键是 Canvas 的使用
- Canvas 的绘制类方法:drawXXX ()(关键参数:Paint)
- Canvas 的辅助类方法:范围裁切 (clipXXX ()) 和几何变换 (Matrix)
- 可以使用不同的绘制方法来控制遮盖关系
自定义绘制知识的四个级别
- Canvas 的 drawXXX () 系列方法及
Paint
最常见的使用Canvas.drawXXX()
是自定义绘制最基本的操作。配合上Paint
的一些常见方法来对绘制内容的颜色和风格进行简单的配置,就能够应付大部分的绘制需求。 - Paint
- Canvas 对绘制的辅助 —— 范围裁切和几何变换。
- 使用不同的绘制方法来控制绘制顺序
onDraw()
提前创建好 Paint
对象,重写 onDraw()
,把绘制代码写在 onDraw()
里面,就是自定义绘制最基本的实现。
var paint = Paint() |
Canvas.drawXXX () 和 Paint 基础
在 Android 里,每个 View 都有一个自己的坐标系,彼此之间是不影响的。这个坐标系的原点是 View 左上角的那个点;水平方向是 x 轴,右正左负;竖直方向是 y 轴,下正上负。
Paint.setColor(int color)
是 Paint
最常用的方法之一,用来设置绘制内容的颜色。
Paint.setStyle(Paint.Style.STROKE)
把绘制模式改为画线模式。Style
具体来说有三种:FILL
,STROKE
和 FILL_AND_STROKE
。FILL
是填充模式,STROKE
是画线模式(即勾边模式),FILL_AND_STROKE
是两种模式一并使用:既画线又填充。它的默认值是 FILL
,填充模式。
在 STROKE
和 FILL_AND_STROKE
下,还可以使用 Paint.setStrokeWidth(float width)
来设置线条的宽度。
在绘制的时候,往往需要开启抗锯齿来让图形和文字的边缘更加平滑。只要在实例化 Paint
的时候加上一个 ANTI_ALIAS_FLAG
参数就行,也可以使用 Paint.setAntiAlias(boolean aa)
来动态开关抗锯齿。
通过 Paint.setTextSize(textSize)
,可以设置文字的大小。
drawColor (@ColorInt int color) 颜色填充
这是最基本的 drawXXX()
方法:在整个绘制区域统一涂上指定的颜色。
例如 drawColor(Color.BLACK)
会把整个区域染成纯黑色,覆盖掉原有内容;drawColor(Color.parse("#88880000")
会在原有的绘制效果上加一层半透明的红色遮罩。
类似的方法还有 drawRGB(int r, int g, int b)
和 drawARGB(int a, int r, int g, int b)
,它们和 drawColor(color)
只是使用方式不同,作用都是一样的。
canvas.drawColor(Color.BLACK) |
这类颜色填充方法一般用于在绘制之前设置底色,或者在绘制之后为界面设置半透明蒙版
drawCircle (float centerX, float centerY, float radius, Paint paint) 画圆
前两个参数 centerX
centerY
是圆心的坐标,第三个参数 radius
是圆的半径,单位都是像素,它们共同构成了这个圆的基本信息;第四个参数 paint
提供基本信息之外的所有风格信息,例如颜色、线条粗细、阴影等。
drawRect (float left, float top, float right, float bottom, Paint paint) 画矩形
left
, top
, right
, bottom
是矩形四条边的坐标。
另外,它还有两个重载方法 drawRect(RectF rect, Paint paint)
和 drawRect(Rect rect, Paint paint)
,让你可以直接填写 RectF
或 Rect
对象来绘制矩形。
drawPoint (float x, float y, Paint paint) 画点
x
和 y
是点的坐标。点的大小可以通过 paint.setStrokeWidth(width)
来设置;点的形状可以通过 paint.setStrokeCap(cap)
来设置:ROUND
画出来是圆形的点,SQUARE
或 BUTT
画出来是方形的点。
Paint.setStrokeCap(cap)
可以设置点的形状,但这个方法并不是专门用来设置点的形状的,而是一个设置线条端点形状的方法。端点有圆头 (ROUND
)、平头 (BUTT
) 和方头 (SQUARE
) 三种。
drawPoints (float [] pts, int offset, int count, Paint paint) /drawPoints (float [] pts, Paint paint) 画点(批量)
同样是画点,它和 drawPoint()
的区别是可以画多个点。pts
这个数组是点的坐标,每两个成一对;offset
表示跳过数组的前几个数再开始记坐标;count
表示一共要绘制几个点。
drawOval (float left, float top, float right, float bottom, Paint paint) 画椭圆
只能绘制横着的或者竖着的椭圆,不能绘制斜的(斜的倒是也可以,但不是直接使用 drawOval()
,而是配合几何变换)。left
, top
, right
, bottom
是这个椭圆的左、上、右、下四个边界点的坐标。另外,它还有一个重载方法 drawOval(RectF rect, Paint paint)
,让你可以直接填写 RectF
来绘制椭圆。
drawLine (float startX, float startY, float stopX, float stopY, Paint paint) 画线
startX
,startY
,stopX
,stopY
分别是线的起点和终点坐标。
由于直线不是封闭图形,所以 setStyle(style)
对直线没有影响。
drawLines (float [] pts, int offset, int count, Paint paint) /drawLines (float [] pts, Paint paint) 画线(批量)
drawLines()
是 drawLine()
的复数版。
drawRoundRect (float left, float top, float right, float bottom, float rx, float ry, Paint paint) 画圆角矩形
left
,top
,right
,bottom
是四条边的坐标,rx
和 ry
是圆角的横向半径和纵向半径。另外,它还有一个重载方法 drawRoundRect(RectF rect, float rx, float ry, Paint paint)
,可以直接填写 RectF
来绘制圆角矩形。
drawArc (float left, float top, float right, float bottom, float startAngle, float sweepAngle, boolean useCenter, Paint paint) 绘制弧形或扇形
drawArc()
是使用一个椭圆来描述弧形的。left
,top
,right
,bottom
描述的是这个弧形所在的椭圆;startAngle
是弧形的起始角度(x 轴的正向,即正右的方向,是 0 度的位置;顺时针为正角度,逆时针为负角度),sweepAngle
是弧形划过的角度;useCenter
表示是否连接到圆心,如果不连接到圆心,就是弧形,如果连接到圆心,就是扇形。
drawPath (Path path, Paint paint) 画自定义图形
drawPath(path)
这个方法是通过描述路径的方式来绘制图形的,它的 path
参数就是用来描述图形路径的对象。path 的类型是
Path`。
Path
可以描述直线、二次曲线、三次曲线、圆、椭圆、弧形、矩形、圆角矩形。把这些图形结合起来,就可以描述出很多复杂的图形。
Path
有两类方法,一类是直接描述路径的,另一类是辅助的设置或计算。
Path 方法第一类:直接描述路径
第一组: addXxx () —— 添加子图形
addCircle (float x, float y, float radius, Direction dir) 添加圆
x
,y
,radius
这三个参数是圆的基本信息,最后一个参数dir
是画圆的路径的方向。路径方向有两种:顺时针 (
CW
clockwise) 和逆时针 (CCW
counter-clockwise) 。对于普通情况,这个参数填CW
还是填CCW
没有影响。它只是在需要填充图形 (Paint.Style
为FILL
或FILL_AND_STROKE
) ,并且图形出现自相交时,用于判断填充范围的。在用
addCircle()
为Path
中新增一个圆之后,调用canvas.drawPath(path,paint)
,就能画一个圆出来。drawPath()
一般是在绘制组合图形时才会用到。ddOval (float left, float top, float right, float bottom, Direction dir) /addOval (RectF oval, Direction dir) 添加椭圆
addRect (float left, float top, float right, float bottom, Direction dir) /addRect (RectF rect, Direction dir) 添加矩形
addRoundRect (RectF rect, float rx, float ry, Direction dir) /addRoundRect (float left, float top, float right, float bottom, float rx, float ry, Direction dir) /addRoundRect (RectF rect, float [] radii, Direction dir) /addRoundRect (float left, float top, float right, float bottom, float [] radii, Direction dir) 添加圆角矩形
addPath (Path path) 添加另一个 Path
第二组:xxxTo () —— 画线(直线或曲线)
lineTo (float x, float y) /rLineTo (float x, float y) 画直线
从当前位置向目标位置画一条直线,
x
和y
是目标位置的坐标。这两个方法的区别是,lineTo(x, y)
的参数是绝对坐标,而rLineTo(x, y)
的参数是相对当前位置的相对坐标(前缀r
指的就是relatively
「相对地」)。当前位置:所谓当前位置,即最后一次调用画
Path
的方法的终点位置。初始值为原点 (0, 0)。quadTo (float x1, float y1, float x2, float y2) /rQuadTo (float dx1, float dy1, float dx2, float dy2) 画二次贝塞尔曲线
这条二次贝塞尔曲线的起点就是当前位置,而参数中的
x1
,y1
和x2
,y2
则分别是控制点和终点的坐标。和rLineTo(x, y)
同理,rQuadTo(dx1, dy1, dx2, dy2)
的参数也是相对坐标。cubicTo (float x1, float y1, float x2, float y2, float x3, float y3) /rCubicTo (float x1, float y1, float x2, float y2, float x3, float y3) 画三次贝塞尔曲线
moveTo (float x, float y) /rMoveTo (float x, float y) 移动到目标位置
不论是直线还是贝塞尔曲线,都是以当前位置作为起点,而不能指定起点。可以通过
moveTo(x, y)
或rMoveTo()
来改变当前位置,从而间接地设置这些方法的起点。moveTo(x, y)
虽然不添加图形,但它会设置图形的起点,所以它是非常重要的一个辅助方法。arcTo (RectF oval, float startAngle, float sweepAngle, boolean forceMoveTo) /arcTo (float left, float top, float right, float bottom, float startAngle, float sweepAngle, boolean forceMoveTo) /arcTo (RectF oval, float startAngle, float sweepAngle) 画弧形
forceMoveTo
参数的意思是,绘制是要「抬一下笔移动过去」,还是「直接拖着笔过去」,区别在于是否留下移动的痕迹。true
不留痕迹。addArc(float left, float top, float right, float bottom, float startAngle, float sweepAngle) / addArc(RectF oval, float startAngle, float sweepAngle)
addArc()
只是一个直接使用了forceMoveTo = true
的简化版arcTo()
。close () 封闭当前子图形
它的作用是把当前的子图形封闭,即由当前位置向当前子图形的起点绘制一条直线。
close()
和lineTo(起点坐标)
是完全等价的。「子图形」:官方文档里叫做
contour
。所谓「子图形」,指的就是一次不间断的连线。一个Path
可以包含多个子图形。当使用第一组方法,即addCircle()
addRect()
等方法的时候,每一次方法调用都是新增了一个独立的子图形;而如果使用第二组方法,即lineTo()
arcTo()
等方法的时候,则是每一次断线(即每一次「抬笔」),都标志着一个子图形的结束,以及一个新的子图形的开始。另外,不是所有的子图形都需要使用
close()
来封闭。当需要填充图形时(即Paint.Style
为FILL
或FILL_AND_STROKE
),Path
会自动封闭子图形。
Path 方法第二类:辅助的设置或计算
这类方法的使用场景比较少。
Path.setFillType (Path.FillType ft) 设置填充方式
Path.setFillType(fillType)
是用来设置图形自相交时的填充算法的,FillType
的取值有四个:
EVEN_ODD
WINDING
(默认值)INVERSE_EVEN_ODD
INVERSE_WINDING
其中后面的两个带有 INVERSE_
前缀的,只是前两个的反色版本。
简单粗暴版的总结,WINDING
是「全填充」,而 EVEN_ODD
是「交叉填充」:
之所以叫「简单粗暴版」,是因为这些只是通常情形下的效果
EVEN_ODD 和 WINDING 的原理
EVEN_ODD
即 even-odd rule(奇偶原则):对于平面中的任意一点,向任意方向射出一条射线,这条射线和图形相交的次数(相交才算,相切不算)如果是奇数,则这个点被认为在图形内部,是要被涂色的区域;如果是偶数,则这个点被认为在图形外部,是不被涂色的区域。以左右相交的双圆为例:
从上图可以看出,射线每穿过图形中的一条线,内外状态就发生一次切换,这就是为什么 EVEN_ODD 是一个「交叉填充」的模式。
WINDING
即 non-zero winding rule (非零环绕数原则):首先,它需要你图形中的所有线条都是有绘制方向的:
然后,同样是从平面中的点向任意方向射出一条射线,但计算规则不一样:以 0 为初始值,对于射线和图形的所有交点,遇到每个顺时针的交点(图形从射线的左边向右穿过)把结果加 1,遇到每个逆时针的交点(图形从射线的右边向左穿过)把结果减 1,最终把所有的交点都算上,得到的结果如果不是 0,则认为这个点在图形内部,是要被涂色的区域;如果是 0,则认为这个点在图形外部,是不被涂色的区域。
如果你所有的图形都用相同的方向来绘制,那么
WINDING
确实是一个「全填充」的规则;但如果使用不同的方向来绘制图形,结果就不一样了。waring
图形的方向:对于添加子图形类方法(如
Path.addCircle()
Path.addRect()
)的方向,由方法的dir
参数来控制;而对于画线类的方法(如Path.lineTo()
Path.arcTo()
),线的方向就是图形的方向。
完整版的 EVEN_ODD
和 WINDING
的效果应该是这样的:
drawBitmap (Bitmap bitmap, float left, float top, Paint paint) 画 Bitmap
绘制 Bitmap
对象,也就是把这个 Bitmap
中的像素内容贴过来。其中 left
和 top
是要把 bitmap
绘制到的位置坐标。
它的重载方法:
drawBitmap(Bitmap bitmap, Rect src, RectF dst, Paint paint)
/drawBitmap(Bitmap bitmap, Rect src, Rect dst, Paint paint)
/drawBitmap(Bitmap bitmap, Matrix matrix, Paint paint)
waring
drawBitmap
还有一个兄弟方法 drawBitmapMesh()
,可以绘制具有网格拉伸效果的 Bitmap。drawBitmapMesh()
的使用场景较少。
drawText (String text, float x, float y, Paint paint) 绘制文字
drawText()
这个方法就是用来绘制文字的。参数 text
是用来绘制的字符串,x
和 y
是绘制的起点坐标。
Paint 详解
drawText () 文字的绘制
Canvas 对绘制的辅助
绘制顺序
super.onDraw () 前 or 后
写在 super.onDraw () 的下面
把绘制代码写在 super.onDraw()
的下面,由于绘制代码会在原有内容绘制结束之后才执行,所以绘制内容就会盖住控件原来的内容。
写在 super.onDraw () 的上面
如果把绘制代码写在 super.onDraw()
的上面,由于绘制代码会执行在原有内容的绘制之前,所以绘制的内容会被控件的原内容盖住。
dispatchDraw ():绘制子 View 的方法
在绘制过程中,每个 View 和 ViewGroup 都会先调用 onDraw()
方法来绘制主体,再调用 dispatchDraw()
方法来绘制子 View。
写在 super.dispatchDraw () 的下面
只要重写 dispatchDraw (),并在 super.dispatchDraw () 的下面写上你的绘制代码,这段绘制代码就会发生在子 View 的绘制之后,从而让绘制内容盖住子 View 了。
写在 super.dispatchDraw () 的上面
把绘制代码写在 super.dispatchDraw()
的上面,这段绘制就会在 onDraw()
之后、super.dispatchDraw()
之前发生,也就是绘制内容会出现在主体内容和子 View 之间。其实和前面重写 onDraw()
并把绘制代码写在 super.onDraw()
之后的做法,效果是一样的。
绘制过程简述
一个完整的绘制过程会依次绘制以下几个内容:
- 背景
- 主体(
onDraw()
) - 子 View(
dispatchDraw()
) - 滑动边缘渐变和滑动条
- 前景
一般来说,一个 View(或 ViewGroup)的绘制不会这几项全都包含,但必然逃不出这几项,并且一定会严格遵守这个顺序。
背景的绘制发生在 drawBackground()
的方法里,但这个方法是 private
的,不能重写,只能用自带的 API 去设置(xml 布局文件的 android:background
属性以及 Java 代码的 View.setBackgroundXxx()
方法),而不能自定义绘制。
滑动边缘渐变和滑动条以及前景,这两部分被合在一起放在 onDrawForeground()
方法里,这个方法是可以重写的。
滑动边缘渐变和滑动条可以通过 xml 的 android:scrollbarXXX
系列属性或 Java 代码的 View.setXXXScrollbarXXX()
系列方法来设置;前景可以通过 xml 的 android:foreground
属性或 Java 代码的 View.setForeground()
方法来设置。而重写 onDrawForeground()
方法,并在它的 super.onDrawForeground()
方法的上面或下面插入绘制代码,则可以控制绘制内容和滑动边缘渐变、滑动条以及前景的遮盖关系。
onDrawForeground ()(API 23 引入)
在 onDrawForeground()
中,会依次绘制滑动边缘渐变、滑动条和前景。
写在 super.onDrawForeground () 的下面
如果绘制代码写在 super.onDrawForeground()
的下面,绘制代码会在滑动边缘渐变、滑动条和前景之后被执行,那么绘制内容将会盖住滑动边缘渐变、滑动条和前景。
写在 super.onDrawForeground () 的上面
如果你把绘制代码写在了 super.onDrawForeground()
的上面,绘制内容就会在 dispatchDraw()
和 super.onDrawForeground()
之间执行,那么绘制内容会盖住子 View,但被滑动边缘渐变、滑动条以及前景盖住。
这种写法,和重写 dispatchDraw()
并把绘制代码写在 super.dispatchDraw()
的下面的效果是一样的:绘制内容都会盖住子 View,但被滑动边缘渐变、滑动条以及前景盖住。
draw () 总调度方法
draw () 是绘制过程的总调度方法。一个 View 的整个绘制过程都发生在 draw()
方法里。
写在 super.draw () 的下面
由于 draw()
是总调度方法,所以如果把绘制代码写在 super.draw()
的下面,那么这段代码会在其他所有绘制完成之后再执行,也就是说,它的绘制内容会盖住其他的所有绘制内容。
写在 super.draw () 的上面
由于 draw()
是总调度方法,所以如果把绘制代码写在 super.draw()
的上面,那么这段代码会在其他所有绘制之前被执行,所以这部分绘制内容会被其他所有的内容盖住,包括背景。
注意
出于效率的考虑,ViewGroup 默认会绕过
draw()
方法,换而直接执行dispatchDraw()
,以此来简化绘制流程。所以如果你自定义了某个ViewGroup
的子类(比如LinearLayout
)并且需要在它的除dispatchDraw()
以外的任何一个绘制方法内绘制内容,你可能会需要调用View.setWillNotDraw(false)
这行代码来切换到完整的绘制流程(是「可能」而不是「必须」的原因是,有些ViewGroup
是已经调用过setWillNotDraw(false)
了的,例如ScrollView
)。有的时候,一段绘制代码写在不同的绘制方法中效果是一样的,这时你可以选一个自己喜欢或者习惯的绘制方法来重写。但有一个例外:如果绘制代码既可以写在
onDraw()
里,也可以写在其他绘制方法里,那么优先写在onDraw()
,因为 Android 有相关的优化,可以在不需要重绘的时候自动跳过onDraw()
的重复执行,以提升开发效率。享受这种优化的只有onDraw()
一个方法。
属性动画 Property Animation 上手
属性动画 Property Animation 进阶
硬件加速
布局基础
布局过程的含义
布局过程,就是程序在运行时利用布局文件的代码来计算出实际尺寸的过程。
布局过程的工作内容
两个阶段:测量阶段和布局阶段。
- 测量阶段:从上到下递归地调用每个 View 或者 ViewGroup 的
measure()
方法,测量他们的尺寸并计算它们的位置; - 布局阶段:从上到下递归地调用每个 View 或者 ViewGroup 的
layout()
方法,把测得的它们的尺寸和位置赋值给它们。
View 或 ViewGroup 的布局过程
测量阶段,
measure()
方法被父 View 调用,在measure()
中做一些准备和优化工作后,调用onMeasure()
来进行实际的自我测量。onMeasure()
做的事,View 和 ViewGroup 不一样:View:View 在
onMeasure()
中会计算出自己的尺寸然后保存;ViewGroup:ViewGroup 在
onMeasure()
中会调用所有子 View 的measure()
让它们进行自我测量,并根据子 View 计算出的期望尺寸来计算出它们的实际尺寸和位置(实际上 99.99% 的父 View 都会使用子 View 给出的期望尺寸来作为实际尺寸)然后保存。同时,它也会根据子 View 的尺寸和位置来计算出自己的尺寸然后保存;
布局阶段,
layout()
方法被父 View 调用,在layout()
中它会保存父 View 传进来的自己的位置和尺寸,并且调用onLayout()
来进行实际的内部布局。onLayout()
做的事,View 和 ViewGroup 也不一样:View:由于没有子 View,所以 View 的
onLayout()
什么也不做。ViewGroup:ViewGroup 在
onLayout()
中会调用自己的所有子 View 的layout()
方法,把它们的尺寸和位置传给它们,让它们完成自我的内部布局。
布局过程自定义的方式
三类:
重写
onMeasure()
来修改已有的 View 的尺寸:重写
onMeasure()
方法,并在里面调用super.onMeasure()
,触发原有的自我测量;在
super.onMeasure()
的下面用getMeasuredWidth()
和getMeasuredHeight()
来获取到之前的测量结果,并使用自己的算法,根据测量结果计算出新的结果;调用
setMeasuredDimension()
来保存新的结果。
重写
onMeasure()
来全新定制自定义 View 的尺寸;重写
onMeasure()
和onLayout()
来全新定制自定义 ViewGroup 的内部布局。
全新定义 View 的尺寸
全新定制尺寸和修改尺寸的最重要区别
需要在计算的同时,保证计算结果满足父 View 给出的的尺寸限制
父 View 的尺寸限制
由来:开发者的要求(布局文件中
layout_
打头的属性)经过父 View 处理计算后的更精确的要求;限制的分类:
UNSPECIFIED
:不限制AT_MOST
:限制上限EXACTLY
:限制固定值
全新定义自定义 View 尺寸的方式
重新
onMeasure()
,并计算出 View 的尺寸;使用
resolveSize()
来让子 View 的计算结果符合父 View 的限制(当然,如果你想用自己的方式来满足父 View 的限制也行)。
定制 Layout 的内部布局
定制 Layout 内部布局的方式
重写
onMeasure()
来计算内部布局重写
onLayout()
来摆放子 View
重写 onMeasure () 的三个步骤:
调用每个子 View 的
measure()
来计算子 View 的尺寸计算子 View 的位置并保存子 View 的位置和尺寸
计算自己的尺寸并用
setMeasuredDimension()
保存
计算子 View 尺寸的关键
计算子 View 的尺寸,关键在于 measure()
方法的两个参数 —— 也就是子 View 的两个 MeasureSpec
的计算。
子 View 的 MeasureSpec 的计算方式:
结合开发者的要求(xml 中
layout_
打头的属性)和自己的可用空间(自己的尺寸上限 - 已用尺寸)尺寸上限根据自己的
MeasureSpec
中的 mode 而定EXACTLY/AT_MOST:尺寸上限为
MeasureSpec
中的size
UNSPECIFIED:尺寸无上限
重写 onLayout()
的方式
在 onLayout()
里调用每个子 View 的 layout()
,让它们保存自己的位置和尺寸。
触摸反馈
自定义触摸反馈的关键:
重写
onTouchEvent()
,在里面写上你的触摸反馈算法,并返回true
(关键是ACTION_DOWN
事件时返回true
)。如果是会发生触摸冲突的 ViewGroup,还需要重写
onInterceptTouchEvent()
,在事件流开始时返回false
,并在确认接管事件流时返回一次true
,以实现对事件的拦截。当子 View 临时需要阻止父 View 拦截事件流时,可以调用父 View 的
requestDisallowInterceptTouchEvent()
,通知父 View 在当前事件流中不再尝试通过onInterceptTouchEvent()
来拦截。