面试系列 不继续了吗?
知道我的人都知道,之前我写了这个 面试系列宣言,如今好像一直都没有连载,而是隔三差五地来一篇,其实也是因为笔者也能力有限,构思一篇文章需要足够的时间去印证其准确性,而之前的部分就因为印证不够造成了勘误。
值得注意的是,本系列不会停止的。面试的很多知识点在于平时的积累,但自定义 View 这个东西,就得牢牢掌握了。自定义 View 将分为几期,本期我们只讲绘制。
为什么我们要学自定义 View?
大多数时候,我们都可以采用官方自带或者 GitHub 上的三方开源库实现各种各样炫酷的效果。但,需求却是五花八门的,你永远无法改变设计师们的想象力和创造力。而我们要做的,就是把他们的想象力和创造力变成现实。
这期怎么变成第二好了?
对,我没有写错,本期自定义 View 教程再也不是最好的了,因为这期基本是 HenCoder 的浓缩总结版。
HenCoder,给高级 Android 工程师的进阶手册 ,笔者也是一直在像追剧一样的追。好像这里确实有了给我凯哥打广告的嫌疑,但把好东西,分享给大家,才是最最重要的。
笔者也是七进七出自定义 View,确实是看了不少教程和书籍,都没有一个很好的自定义 View 能力。而作为 Android 开发中必不可少的能(装)力(逼)手段,也是一个很好的可以让我们在面试以及开发中脱颖而出。
废话不能太多,我要开始啦!
自定义 View 可以简单的分为三步,绘制、布局、触摸反馈。本期,我们首先讲绘制。
自定义 View 绘制的重中之重
自定义的绘制就是重写绘制方法,其中最常用的就是 onDraw()
。(当然有其它的,后面会提及,这里先卖个关子。)而绘制的关键就是 Canvas
的使用:
- Canvas 的绘制类方法:drawXXX() (关键参数:Paint)
- Canvas 的辅助类方法:范围裁切和几何变换。
一切的开始:onDraw()
自定义绘制的上手非常容易:提前创建好 Paint
对象,重写 onDraw()
,把绘制代码写在 onDraw()
里面,就是自定义绘制最基本的实现。大概就像这样:
Paint paint = new Paint();
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制一个圆
canvas.drawCircle(300, 300, 200, paint);
}
就这么简单。所以关于 onDraw()
其实没什么好说的,一个很普通的方法重写,唯一需要注意的是别漏写了 super.onDraw()
。你可能会点击进去查看到 super.onDraw()
其实是一个空实现,那可能只是因为你继承的是 View
吧,你继承 View 的其它子类试试?
Canvas.drawXXX() 系列方法的使用
Canvas
下面的 drawXXX() 系列的方法真没啥好讲的,你想画什么图形直接画就好了。而参数其实也给的非常的明了。你一定要全部了解学习的话,直接可以去看官方文档或者凯哥的 自定义View 1-1
- 填充颜色:Canvas.drawColor(@ColorInt int color)
- 画圆:drawCircle(float centerX, float centerY, float radius, Paint paint)
- 画矩形:drawRect(float left, float top, float right, float bottom, Paint paint)
- 画点:drawPoint(float x, float y, Paint paint)
- 批量画点:drawPoints(float[] pts, int offset, int count, Paint paint) / drawPoints(float[] pts, Paint paint)
- 画椭圆:drawOval(float left, float top, float right, float bottom, Paint paint)
- 画线:drawLine(float startX, float startY, float stopX, float stopY, Paint paint)
- 画弧线或者扇形:drawArc(float left, float top, float right, float bottom, float startAngle, float sweepAngle, boolean useCenter, Paint paint)
- 画自定义图形:drawPath(Path path, Paint paint)
- 画 Bitmap:drawBitmap(Bitmap bitmap, float left, float top, Paint paint)
- 画文字:drawText(String text, float x, float y, Paint paint)
其中可以看到有不少的坐标值参数,你只需要明白的一点是,在 Android 的绘制中,坐标系是这样的。
值得注意的是:
- 在画弧线或者扇形中的角度 angle,x 轴正方向为 0°,顺时针方向为正角度,逆时针为负角度。
- 画弧线或者扇形中的
sweepAngle
参数,代表的是绘制的角度,不要被其它方法误导成了以为是绘制结束时候的角度,官方为何在这里做了个变换,其实我也不知道。 drawPath()
方法可能相对其它较难,但却是自定义 View 实际应用中最多的。非常需要了解其三类方法。这里直接摘抄凯哥的 自定义 View 1-1。drawBitmap()
方法中有个参数是 Bitmap,友情提示:Bitmap 可以通过BitmapFactory.decodeXXX()
获得。
Path 可以描述直线、二次曲线、三次曲线、圆、椭圆、弧形、矩形、圆角矩形。把这些图形结合起来,就可以描述出很多复杂的图形。Path 可以归结为两类方法:
- 直接描述路径,也可以分为两组:
- 添加子图形:
addXXX()
, 此类方法在特定情况下几个Canvas.drawPath()
等同于Canvas.drawXXX()
。- 画直线或曲线:
xxxTo()
: 这一组和第一组addXxx()
方法的区别在于,第一组是添加的完整封闭图形(除了addPath()
),而这一组添加的只是一条线。- 辅助设置或计算,因为应用场景很少,凯哥也只讲了其中一个方法:
Path.setFillType(Path.FillType ft)
设置填充方式
上面有比较多的提到 Paint 这个参数,实际上它是真的很好用,直接在下面讲解。
Paint 的使用
Paint 真的很重要,在自定义绘制中充当关键角色:画笔,所以我们自然可以为「画笔」做很多操作,比如设置颜色、绘制模式、粗细等。
- Paint.setStyle(Style style) 设置绘制模式
- Paint.setColor(int color) 设置颜色
- Paint.setStrokeWidth(float width) 设置线条宽度
- Paint.setTextSize(float textSize) 设置文字大小
- Paint.setAntiAlias(boolean aa) 设置抗锯齿开关
嗯,对,抗锯齿开关还可以直接在 Paint 初始化的时候直接作为构造参数:Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG)
Paint 的 API 大致可以分为 4 类:
- 颜色
- 效果
- drawText() 相关
- 初始化
凯哥专门拿了一期对 Paint 做了重点讲解,依然在实际场景应该用处不大,所以需要的直接点击 这里 跳转。
如果你想先知道凯哥都讲了什么,我这里也单独给你总结一下:
首先是给 Paint 设置着色器。
- Paint.setShader(Shader shader):设置着色器,实际上我们一般传递的参数不会直接传递
Shader
,而会选择直接传递它的子类,具体效果下面给出。
线性渐变:LinearGradient(float x0, float y0, float x1, float y1, int color0, int color1,TileMode tile)
辐射渐变:RadialGradient(float centerX, float centerY, float radius,
int centerColor, int edgeColor, @NonNull TileMode tileMode)
扫描渐变:SweepGradient(float cx, float cy, int color0, int color1)
还有很多,就不一一给图了。
- BitmapShader(@NonNull Bitmap bitmap, TileMode tileX, TileMode tileY)
- 混合着色:ComposeShader(Shader shaderA, Shader shaderB, PorterDuff.Mode mode)
其中需要注意的是:
Paint.setShader()
优先级高于Paint.setColor()
系列方法。- 最后一个 tile 参数,代表的是断点范围之外的着色规则。它是一个枚举类型,有三种参数。
- CLAMP : 直译是「夹子模式」,会在端点之外延续端点处的颜色。
- MIRROR : 镜像模式。
- REPEAT : 重复模式。
其次是设置颜色过滤
设置颜色过滤可以采用 Paint.setColorFilter(ColorFilter colorFilter)
方法。它的名字已经足够解释它的作用:为绘制设置颜色过滤。颜色过滤的意思,就是为绘制的内容设置一个统一的过滤策略,然后 Canvas.drawXXX()
方法会对每个像素都进行过滤后再绘制出来。
这个其实貌似在拍照或者照片整理类应用上用的比较多,其它方面貌似我还很少遇到过,GitHub 上的库 StyleImageView 诠释的很棒。
再其它也就没啥好说的,感兴趣直接去看 HenCoder 吧
这里可以重点说一下:Paint.setStrokeCap(Paint.Cap cap)
,设置线头的形状。线头形状有三种:BUTT
平头、ROUND
圆头、SQUARE
方头。默认为 BUTT
。
虚线是额外加的,虚线左边是线的实际长度,虚线右边是线头。有了虚线作为辅助,可以清楚地看出 BUTT 和 SQUARE 的区别。
Canvas 的文字绘制
Canvas 的文字绘制方法有三个:
- drawText()
- drawTextRun()
- drawTextOnPath()
我们大多数情况用不了那么多,所以同样这里不做详解,对于始终想追根到底的同学,同样给你提供了 凯哥的链接。
下面只对部分需要注意的重点总结一下。
drawText()
drawText(@NonNull String text, float x, float y, @NonNull Paint paint)
其中的参数很简单:text 是文字内容,x 和 y 是文字的坐标。但需要注意:这个坐标并不是文字的左上角,而是一个与左下角比较接近的位置。大概在这里:
而如果你像绘制其他内容一样,在绘制文字的时候把坐标填成 (0, 0),文字并不会显示在 View 的左上角,而是会几乎完全显示在 View 的上方,到了 View 外部看不到的位置:canvas.drawText(text, 0, 0, paint);
大概是这样:
另外,Canvas.drawText()
只能绘制单行的文字,而不能换行。就算显示不完,也会直接绘制到屏幕外面去。
那如果要换行,得 drawText()
很多次吗?并没有,还有一个 StaticLayout
可以完美达到我们的效果。对于详细使用,这里也不多提了。
对 drawTextRun()
和 drawTextOnPath()
,运用的可能并不多,这里就不说了。
简单提一下设置效果辅助类吧,这个可能直接就有用。
Paint 对文字绘制的辅助
- 设置文字大小:
Paint.setTextSize(float textSize)
- 设置字体:
Paint.setTypeface(Typeface typeface)
,其中的 Typeface 里面涵盖了相关字体。另外,还可以通过Typeface.createFromAsset(AssetManager mgr, String path)
来设置自定义字体,其中mgr
可以给getResources().getAssets()
,path
给文件名字,需要把字体文件 .ttf 放在工程的 res/assets 下,「assets」是新建的专用目录。 - 设置文字是否加粗:
Paint.setFakeBoldText(boolean fakeBoldText)
- 设置文字是否加删除线:
Paint.setStrikeThruText(boolean strikeThruText)
- 设置文字是否加下划线:
Paint.setUnderlineText(boolean underlineText)
- 设置字体倾斜度:
Paint.setTextSkewX(float skewX)
「skewX」 向左倾斜为正。 - 设置文字横向放缩:
Paint.setTextScaleX(float scaleX)
- 设置字体间距,默认值为 0:
Paint.setLetterSpacing(float letterSpacing)
这个不是行间距哦。 - 设置文字对齐方式:
Paint.setTextAlign(Paint.Align align)
,其中「align」有三个值:LEFT
、CENTER
和RIGHT
,默认值是LEFT
。 - 设置绘制所使用的 Locale:
Paint.setTextLocale(Locale locale)
/Paint.setTextLocales(LocaleList locales)
实际上,这些方法基本都在我们 TextView 里面的。
自定义 View 之范围裁切
范围裁切主要采用两个方法:
- clipRect()
- clipPath()
clipRect()
很简单,只需要传递和 RectF
一样的参数即可。你可以除了裁剪矩形,还想做其它样式的裁剪,可惜这里只有通过 path 的方法了(我也很奇怪为啥没有看到其它方法),再一次印证了 path 的重要性有木有。
值得注意的是:我们通常会在范围裁切前后加上 Canvas.save()
和 Canvas.restore()
来及时恢复绘制范围。大概代码是这样。
canvas.save();
canvas.clipRect(left, top, right, bottom);
canvas.drawBitmap(bitmap, x, y, paint);
canvas.restore();
另一个值得注意的点是:一定是先做范围裁切操作,再做 Canvas.drawXXX()
操作,顺序放反的话你会发现毛效果都没有。除了裁切,几何变换也是如此。
几何变换
几何变换的使用大概分为三类:
- 使用
Canvas
来做常见的二维变换; - 使用
Matrix
来做常见和不常见的二维变换; - 使用
Camera
来做三维变换
直接采用 Canvas 自带方法进行二维变换
-
Canvas.translate(float dx, float dy)
平移,其中,dx 和 dy 分别表示横向和纵向的位移。 -
Canvas.rotate(float degrees, float px, float py)
旋转,其中degrees
是旋转角度,顺时针为正向,px
和py
代表轴心坐标。 -
Canvas.scale(float sx, float sy, float px, float py)
放缩,其中 sx,sy 分别是横向和纵向的放缩倍数,px 、py 为放缩的轴心,这里千万不要受到重载方法Canvas.scale(float sx,float sy)
的影响。 -
skew(float sx, float sy)
错切。这里的 sx 和 sy 分别是 x 方向和 y 方向的错切系数。值得注意的是,这里 sx 和 sy 值为 0 的时候代表自己的方向不错切。
再次重申,需要先做了二维变换,再执行 「drawXXX」操作,重要的事情一定会说三遍。
二维变换的另一种方式 —— Matrix
用 Matrix
做常见变换的基本套路
- 创建 Matrix 对象;
- 调用 Matrix 的 pre/postTranslate/Rotate/Scale/Skew() 方法来设置几何变换;
- 使用 Canvas.setMatrix(matrix) 或 Canvas.concat(matrix) 来把几何变换应用到 Canvas。
Matrix matrix = new Matrix();
...
matrix.reset();
matrix.postTranslate();
matrix.postRotate();
canvas.save();
canvas.concat(matrix);
canvas.drawBitmap(bitmap, x, y, paint);
canvas.restore();
把 Matrix 应用到 Canvas
有两个方法: Canvas.setMatrix(matrix)
和 Canvas.concat(matrix)
。
Canvas.setMatrix(matrix)
:用 Matrix 直接替换 Canvas 当前的变换矩阵,即抛弃 Canvas 当前的变换,改用 Matrix 的变换(注:根据凯哥收到的反馈,不同的系统中setMatrix(matrix)
的行为可能不一致,所以还是尽量用concat(matrix)
吧);Canvas.concat(matrix)
:用 Canvas 当前的变换矩阵和 Matrix 相乘,即基于 Canvas 当前的变换,叠加上 Matrix 中的变换。
其中需要注意的是:当多个 Matrix
需要用到的时候,你并不需要初始化多个 Matrix
,而可以直接通过调用 Matrix.reset()
对 Matrix
进行重置。
对于采用 Matrix
来实现不规则变换以及采用 Camera
实现三维变换这里也就不多说了,实际遇到的时候,你也可以 点击这里 复习一下呀。
精彩的绘制顺序
前面讲了一大堆绘制方法,以及范围裁切和变换,我们这里再说说绘制顺序。
Android 里面的绘制都是按顺序的,先绘制的内容会被后绘制的盖住。比如你在重叠的位置先画圆再画方,和先画方再画圆所呈现出来的结果肯定是不同的:
到底放在 super.onDraw() 上面还是下面?
通常如果我们继承的是 View 的话,super.onDraw() 只是一个空实现,所以它的位置放在哪儿都没事,甚至直接不要也没事,但反正加上也没啥影响,尽量还是加上吧。
由于 Android 的绘制顺序性,当你继承自已经有绘制的其他 View(比如 TextView)的时候,放在 super.onDraw()
上面就意味着绘制代码会被控件的原内容盖住。
dispatchDraw():绘制子 View 的方法
还记得我上面卖的关子吗?自定义绘制其实不止 onDraw()
一个方法。onDraw()
只是负责自身主体内容绘制的。而有的时候,你想要的遮盖关系无法通过 onDraw()
来实现,而是需要通过别的绘制方法。
凯哥这块真的写的是太有意思了,所以我也是直接 copy 了过来。
例如,你继承了一个 LinearLayout,重写了它的 onDraw() 方法,在 super.onDraw() 中插入了你自己的绘制代码,使它能够在内部绘制一些斑点作为点缀:
public class SpottedLinearLayout extends LinearLayout {
...
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
... // 绘制斑点
}
}
看起来确实没有问题,但是你会发现,当你添加了子 View 之后,你的斑点不见了:
造成这种情况的原因是 Android 的绘制顺序:在绘制过程中,每一个 ViewGroup 会先调用自己的
onDraw()
来绘制完自己的主体之后再去绘制它的子 View。对于上面这个例子来说,就是你的 LinearLayout 会在绘制完斑点后再去绘制它的子 View。那么在子 View 绘制完成之后,先前绘制的斑点就被子 View 盖住了。
具体来讲,这里说的「绘制子 View」是通过另一个绘制方法的调用来发生的,这个绘制方法叫做:dispatchDraw()
。也就是说,在绘制过程中,每个 View 和 ViewGroup 都会先调用 onDraw()
方法来绘制主体,再调用 dispatchDraw()
方法来绘制子 View。
注:虽然 View 和 ViewGroup 都有
dispatchDraw()
方法,不过由于 View 是没有子 View 的,所以一般来说dispatchDraw()
这个方法只对 ViewGroup(以及它的子类)有意义。
回到刚才的问题:怎样才能让 LinearLayout 的绘制内容盖住子 View 呢?只要让它的绘制代码在子 View 的绘制之后再执行就好了。所以直接执行在
super.dispatchDraw()
的下面即可。
简单总结一下绘制顺序
凯哥确实强势,在文章的最后,直接贴图,不能再清晰了,所以我也是直接跳过了其中 N 个环节,直接上图。
注意:
- 在 ViewGroup 的子类中重写除
dispatchDraw()
以外的绘制方法时,可能需要调用setWillNotDraw(false)
;- 在重写的方法有多个选择时,优先选择
onDraw()
。
写在最后
本期的自定义 View 之绘制就到这里结束了,强烈推荐 点击链接 跟着凯哥操,不得挨飞刀。
做不完的开源,写不完的矫情。欢迎扫描下方二维码或者公众号搜索「nanchen」关注我的微信公众号,目前多运营 Android ,尽自己所能为你提升。如果你喜欢,为我点赞分享吧~