Android 自定义 View

绘制基础

自定义绘制概述

  • 自定义绘制的方式是重写绘制方法,其中最常用的是 onDraw()
  • 绘制的关键是 Canvas 的使用
    • Canvas 的绘制类方法:drawXXX ()(关键参数:Paint)
    • Canvas 的辅助类方法:范围裁切 (clipXXX ()) 和几何变换 (Matrix)
  • 可以使用不同的绘制方法来控制遮盖关系

自定义绘制知识的四个级别

  1. Canvas 的 drawXXX () 系列方法及 Paint 最常见的使用 Canvas.drawXXX() 是自定义绘制最基本的操作。配合上 Paint 的一些常见方法来对绘制内容的颜色和风格进行简单的配置,就能够应付大部分的绘制需求。
  2. Paint
  3. Canvas 对绘制的辅助 —— 范围裁切和几何变换。
  4. 使用不同的绘制方法来控制绘制顺序

onDraw()

提前创建好 Paint 对象,重写 onDraw(),把绘制代码写在 onDraw() 里面,就是自定义绘制最基本的实现。

var paint = Paint()

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制一个圆
canvas.drawCircle(300F, 300F, 200F, paint)
}

Canvas.drawXXX () 和 Paint 基础

在 Android 里,每个 View 都有一个自己的坐标系,彼此之间是不影响的。这个坐标系的原点是 View 左上角的那个点;水平方向是 x 轴,右正左负;竖直方向是 y 轴,下正上负。

Paint.setColor(int color)Paint 最常用的方法之一,用来设置绘制内容的颜色。

Paint.setStyle(Paint.Style.STROKE) 把绘制模式改为画线模式。Style具体来说有三种:FILL,STROKEFILL_AND_STROKEFILL 是填充模式,STROKE 是画线模式(即勾边模式),FILL_AND_STROKE 是两种模式一并使用:既画线又填充。它的默认值是 FILL,填充模式。

STROKEFILL_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)
canvas.drawColor(Color.parseColor("#88880000"))
canvas.drawRGB(100, 200, 100)
canvas.drawARGB(100, 100, 200, 100)

这类颜色填充方法一般用于在绘制之前设置底色,或者在绘制之后为界面设置半透明蒙版

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),让你可以直接填写 RectFRect 对象来绘制矩形。

drawPoint (float x, float y, Paint paint) 画点

xy 是点的坐标。点的大小可以通过 paint.setStrokeWidth(width) 来设置;点的形状可以通过 paint.setStrokeCap(cap) 来设置:ROUND 画出来是圆形的点,SQUAREBUTT 画出来是方形的点。

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 是四条边的坐标,rxry 是圆角的横向半径和纵向半径。另外,它还有一个重载方法 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.StyleFILLFILL_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) 画直线

    当前位置向目标位置画一条直线,xy 是目标位置的坐标。这两个方法的区别是,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,y1x2,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.StyleFILLFILL_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 是「交叉填充」:

图 5

之所以叫「简单粗暴版」,是因为这些只是通常情形下的效果

EVEN_ODD 和 WINDING 的原理
  • EVEN_ODD

    即 even-odd rule(奇偶原则):对于平面中的任意一点,向任意方向射出一条射线,这条射线和图形相交的次数(相交才算,相切不算)如果是奇数,则这个点被认为在图形内部,是要被涂色的区域;如果是偶数,则这个点被认为在图形外部,是不被涂色的区域。以左右相交的双圆为例:

    图 1

    从上图可以看出,射线每穿过图形中的一条线,内外状态就发生一次切换,这就是为什么 EVEN_ODD 是一个「交叉填充」的模式。

  • WINDING

    即 non-zero winding rule (非零环绕数原则):首先,它需要你图形中的所有线条都是有绘制方向的:

    图 2

    然后,同样是从平面中的点向任意方向射出一条射线,但计算规则不一样:以 0 为初始值,对于射线和图形的所有交点,遇到每个顺时针的交点(图形从射线的左边向右穿过)把结果加 1,遇到每个逆时针的交点(图形从射线的右边向左穿过)把结果减 1,最终把所有的交点都算上,得到的结果如果不是 0,则认为这个点在图形内部,是要被涂色的区域;如果是 0,则认为这个点在图形外部,是不被涂色的区域。

    图 3

    如果你所有的图形都用相同的方向来绘制,那么 WINDING 确实是一个「全填充」的规则;但如果使用不同的方向来绘制图形,结果就不一样了。

    waring

    图形的方向:对于添加子图形类方法(如 Path.addCircle() Path.addRect())的方向,由方法的 dir 参数来控制;而对于画线类的方法(如 Path.lineTo() Path.arcTo()),线的方向就是图形的方向。

完整版的 EVEN_ODDWINDING 的效果应该是这样的:

图 4

drawBitmap (Bitmap bitmap, float left, float top, Paint paint) 画 Bitmap

绘制 Bitmap 对象,也就是把这个 Bitmap 中的像素内容贴过来。其中 lefttop 是要把 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 是用来绘制的字符串,xy 是绘制的起点坐标。

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() 的上面,那么这段代码会在其他所有绘制之前被执行,所以这部分绘制内容会被其他所有的内容盖住,包括背景。

图 1

注意

  1. 出于效率的考虑,ViewGroup 默认会绕过 draw() 方法,换而直接执行 dispatchDraw(),以此来简化绘制流程。所以如果你自定义了某个 ViewGroup 的子类(比如 LinearLayout)并且需要在它的除 dispatchDraw() 以外的任何一个绘制方法内绘制内容,你可能会需要调用 View.setWillNotDraw(false) 这行代码来切换到完整的绘制流程(是「可能」而不是「必须」的原因是,有些 ViewGroup 是已经调用过 setWillNotDraw(false) 了的,例如 ScrollView)。

  2. 有的时候,一段绘制代码写在不同的绘制方法中效果是一样的,这时你可以选一个自己喜欢或者习惯的绘制方法来重写。但有一个例外:如果绘制代码既可以写在 onDraw() 里,也可以写在其他绘制方法里,那么优先写在 onDraw(),因为 Android 有相关的优化,可以在不需要重绘的时候自动跳过 onDraw() 的重复执行,以提升开发效率。享受这种优化的只有 onDraw() 一个方法。

图 2

属性动画 Property Animation 上手

属性动画 Property Animation 进阶

硬件加速

布局基础

布局过程的含义

布局过程,就是程序在运行时利用布局文件的代码来计算出实际尺寸的过程。

布局过程的工作内容

两个阶段:测量阶段和布局阶段。

  • 测量阶段:从上到下递归地调用每个 View 或者 ViewGroup 的 measure() 方法,测量他们的尺寸并计算它们的位置;
  • 布局阶段:从上到下递归地调用每个 View 或者 ViewGroup 的 layout() 方法,把测得的它们的尺寸和位置赋值给它们。

View 或 ViewGroup 的布局过程

  1. 测量阶段,measure() 方法被父 View 调用,在 measure() 中做一些准备和优化工作后,调用 onMeasure() 来进行实际的自我测量。onMeasure() 做的事,View 和 ViewGroup 不一样:

    1. View:View 在 onMeasure() 中会计算出自己的尺寸然后保存;

    2. ViewGroup:ViewGroup 在 onMeasure() 中会调用所有子 View 的 measure() 让它们进行自我测量,并根据子 View 计算出的期望尺寸来计算出它们的实际尺寸和位置(实际上 99.99% 的父 View 都会使用子 View 给出的期望尺寸来作为实际尺寸)然后保存。同时,它也会根据子 View 的尺寸和位置来计算出自己的尺寸然后保存;

  2. 布局阶段,layout() 方法被父 View 调用,在 layout() 中它会保存父 View 传进来的自己的位置和尺寸,并且调用 onLayout() 来进行实际的内部布局。onLayout() 做的事,View 和 ViewGroup 也不一样:

    1. View:由于没有子 View,所以 View 的 onLayout() 什么也不做。

    2. ViewGroup:ViewGroup 在 onLayout() 中会调用自己的所有子 View 的 layout() 方法,把它们的尺寸和位置传给它们,让它们完成自我的内部布局。

布局过程自定义的方式

三类:

  1. 重写 onMeasure() 来修改已有的 View 的尺寸:

    1. 重写 onMeasure() 方法,并在里面调用 super.onMeasure(),触发原有的自我测量;

    2. super.onMeasure() 的下面用 getMeasuredWidth()getMeasuredHeight() 来获取到之前的测量结果,并使用自己的算法,根据测量结果计算出新的结果;

    3. 调用 setMeasuredDimension() 来保存新的结果。

  2. 重写 onMeasure() 来全新定制自定义 View 的尺寸;

  3. 重写 onMeasure()onLayout() 来全新定制自定义 ViewGroup 的内部布局。

全新定义 View 的尺寸

全新定制尺寸和修改尺寸的最重要区别

需要在计算的同时,保证计算结果满足父 View 给出的的尺寸限制

父 View 的尺寸限制

  1. 由来:开发者的要求(布局文件中 layout_打头的属性)经过父 View 处理计算后的更精确的要求;

  2. 限制的分类:

    1. UNSPECIFIED:不限制

    2. AT_MOST:限制上限

    3. EXACTLY:限制固定值

全新定义自定义 View 尺寸的方式

  1. 重新 onMeasure(),并计算出 View 的尺寸;

  2. 使用 resolveSize() 来让子 View 的计算结果符合父 View 的限制(当然,如果你想用自己的方式来满足父 View 的限制也行)。

定制 Layout 的内部布局

定制 Layout 内部布局的方式

  1. 重写 onMeasure() 来计算内部布局

  2. 重写 onLayout() 来摆放子 View

重写 onMeasure () 的三个步骤:

  1. 调用每个子 View 的 measure() 来计算子 View 的尺寸

  2. 计算子 View 的位置并保存子 View 的位置和尺寸

  3. 计算自己的尺寸并用 setMeasuredDimension() 保存

计算子 View 尺寸的关键

计算子 View 的尺寸,关键在于 measure() 方法的两个参数 —— 也就是子 View 的两个 MeasureSpec 的计算。

子 View 的 MeasureSpec 的计算方式:

  • 结合开发者的要求(xml 中 layout_打头的属性)和自己的可用空间(自己的尺寸上限 - 已用尺寸)

  • 尺寸上限根据自己的 MeasureSpec 中的 mode 而定

    • EXACTLY/AT_MOST:尺寸上限为 MeasureSpec 中的 size

    • UNSPECIFIED:尺寸无上限

重写 onLayout() 的方式

onLayout() 里调用每个子 View 的 layout(),让它们保存自己的位置和尺寸。

触摸反馈

自定义触摸反馈的关键:

  1. 重写 onTouchEvent(),在里面写上你的触摸反馈算法,并返回 true(关键是 ACTION_DOWN 事件时返回 true)。

  2. 如果是会发生触摸冲突的 ViewGroup,还需要重写 onInterceptTouchEvent(),在事件流开始时返回 false,并在确认接管事件流时返回一次 true,以实现对事件的拦截。

  3. 当子 View 临时需要阻止父 View 拦截事件流时,可以调用父 View 的 requestDisallowInterceptTouchEvent(),通知父 View 在当前事件流中不再尝试通过 onInterceptTouchEvent() 来拦截。