前言
自定义 View
有几种实现类型,分别为:
继承自
View
完全自定义;继承自现有控件(如
ImageView
)实现特定效果;继承自
ViewGroup
实现布局类。
比较重要的知识点是 View
的测量与布局、View
的绘制、处理触摸事件、动画等。
一、最为自由的一种实现 — 自定义 View
对于继承自 View
类的自定义控件来说,核心的步骤分别为尺寸测量和绘制,对应的函数是 onMeasure()
,onDraw()
。这里我们讨论的 View 类型的子类是非 ViewGroup 类型,属于视图树的叶子节点,因此,它只负责绘制好自身内容即可,而这两步就是完成它职责的所有工作。
下面我们来简单实现一个显示图片的 ImageView
,它能根据用户设置的大小将图片缩放,使得图片在任何尺寸下都能够正确显示:
/**
* 简单的ImageView,用于显示图片
*/
public class SimpleImageView extends View {
// 画笔
private Paint mBitmapPaint;
// 图片drawable
private Drawable mDrawable;
// 要绘制的图片
Bitmap mBitmap;
// view的宽度
private int mWidth;
// view的高度
private int mHeight;
public SimpleImageView(Context context) {
this(context, null);
}
public SimpleImageView(Context context, AttributeSet attrs) {
super(context, attrs);
//根据属性初始化
initAttrs(attrs);
//初始化画笔
mBitmapPaint = new Paint();
//抗锯齿
mBitmapPaint.setAntiAlias(true);
//设置颜色
mBitmapPaint.setColor(Color.RED);
}
private void initAttrs(AttributeSet attrs) {
if (attrs != null) {
TypedArray array = null;
try {
//获取自定义View的属性集
array = getContext().obtainStyledAttributes(attrs, R.styleable.SimpleImageView);
//根据图片id获取到Drawable对象
mDrawable = array.getDrawable(R.styleable.SimpleImageView_src);
//测量Drawable对象的宽和高
measureDrawable();
} finally {
if (array != null) {
array.recycle();
}
}
}
}
首先我们创建一个继承自 View
的 SimpleImageView
类,在构造函数中获取该控件的属性,并且初始化要绘制的图片和画笔。我们在 values/attr.xml
中定义这个 View
的属性,attr.xml
中的内容如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="SimpleImageView">
<attr name="src" format="integer" />
</declare-styleable>
</resources>
该属性集的名字为 SimpleImageView
,里面只有一个名为 src
的整型属性。我们通过这个属性为 SimpleImageView
设置图片的资源 id
。代码如下:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.example.jerry.myapplication.activity.MainActivity">
<com.example.jerry.myapplication.fjtm.SimpleImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
img:src="@drawable/roger_federer" />
</RelativeLayout>
注意:在使用自定义的属性时,我们需要将该属性所在的命名空间引入到 xml 文件中,命名空间实际上就是该工程的应用包名。因为自定义的属性集最终会编译为 R 类,R 类的完整路径是
应用的包名.R
。
比如我的应用包名为 com.example.jerry.application
,因此,我们引入了一个名为 img
的命名空间,它的格式为:
xmlns:名字="http://schemas.android.com/apk/res/包名"
xmlns:img="http://schemas.android.com/apk/res/com.example.jerry.myapplication"
其实有没有觉得这很麻烦,同一个包名可以指定的命名空间是多种多样的,然而我们还要为不同的命名空间声明同样的值。就连 Android Studio 都看不过眼了:
上面的意思就是说,我们只需要引入
xmlns:app="http://schemas.android.com/apk/res-auto"
这个命名空间,编译时就会自动帮我们找到对应的命名空间而不用传入具体的包名。如下,将 img:src
改为 app:src
。
<com.example.jerry.myapplication.fjtm.SimpleImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:src="@drawable/roger_federer" />
当应用启动时会从这个 xml 布局中解析出 SimpleImageView
的属性,宽度和高度都为 wrap_content
,src 属性为 drawable 目录下的 roger_federer,进入构造函数后会调用 initAttrs()
函数进行初始化。
在 initAttrs()
函数中,我们先读取 SimpleImageView
的属性集 TypedArray;再从该对象中读取 SimpleImageView_src
属性值,该属性是一个 drawable 的资源 id 值。然后我们根据这个 id 从 TypedArray 对象中获取到该 id 对应的 Drawable;最后我们调用 measureDrawable()
函数测量该图片的大小。代码如下:
private void measureDrawable() {
if (mDrawable == null) {
throw new RuntimeException("drawable不能为空!");
}
//获取Drawable的固有宽度和高度,返回的单位是dp
mWidth = mDrawable.getIntrinsicWidth();
mHeight = mDrawable.getIntrinsicHeight();
Log.e(VIEW_LOG_TAG, "### width = " + mWidth + ", height = " + mHeight);
}
我们将图片的宽高设给 SimpleImageView,也就是说图片多大,SimpleImageView 就有多大。注意:getIntrinsicWidth()
和 getIntrinsicHeight()
获取的是 Drawable 的固有宽高,但是它们返回的单位是 dp,所以显示出来的图片有可能比原来的图片大或小。测量结束之后,接下来就是绘制该视图了。代码如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//设置View的宽高为图片的宽高
setMeasuredDimension(mWidth, mHeight);
}
@Override
protected void onDraw(Canvas canvas) {
if (mDrawable == null) {
return;
}
// 绘制图片
canvas.drawBitmap(ImageUtils.drawableToBitamp(mDrawable),
getLeft(), getTop(), mBitmapPaint);
}
}
ImageUtils 类是用来将 Drawable 转为 Bitmap 的,代码如下:
public final class ImageUtils {
private ImageUtils() {
}
/**
* drawable转bitmap
*
* @param drawable
* @return
*/
public static Bitmap drawableToBitamp(Drawable drawable) {
if (drawable instanceof BitmapDrawable) {
BitmapDrawable bd = (BitmapDrawable) drawable;
return bd.getBitmap();
}
int w = drawable.getIntrinsicWidth();
int h = drawable.getIntrinsicHeight();
Bitmap bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_4444);
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0, 0, w, h);
drawable.draw(canvas);
return bitmap;
}
}
最后我们来总结一下这个过程:
继承自
View
创建自定义控件;如有需要自定义
View
属性,也就是在values/attrs.xml
中定义属性集;在 xml 中引入命名文件,设置属性;(最新的 Android Studio 已经可以帮我们省去这一步,使用
app
命名空间即可,详情请回顾前文。)在代码中读取 xml 中的属性,初始化视图;
测量视图大小 —
onMeasure()
;绘制视图内容 —
onDraw()
:使用canvas
(画布) 和paint
(画笔) 绘制。
疑问?
如果 SimpleImageView 的宽、高设置为 match_parent
会怎么样,设置为指定的大小又会正常显示吗?
总结: 视图的 left,top,right,bottom 的值是针对其父视图的相对位置。
getTop 确实是 View 顶部距离父容器顶部的距离,但是:getBottom 却是 View 底部距离父容器顶部的距离,并不是距离父容器底部。
这里在补充一个知识点:getBottom 的值就等于 getTop + View.getMeasuredHeight()。
二、View 的尺寸测量
对于非 ViewGroup 类型来说,视图布局 — onLayout()
这个步骤是不需要的,因为它并不是一个视图容器。它只需要完成测量尺寸和绘制自身内容的工作,上述 SimpleImageView 就是这样的例子。
但是,SimpleImageView 的尺寸测量只能根据图片的大小进行设置,如果用户想支持 match_parent 和具体的宽高值则不会生效,SimpleImageView 的宽高还是图片的宽高。因此,我们需要根据用户设置的宽高模式来计算 SimpleImageView 的尺寸,而不是一概地使用图片的宽高值作为视图的宽高。
在视图树渲染时,View 系统的绘制流程会从 ViewRoot 的 performTraversals()
方法中开始,在其内部调用 View 的 measure()
方法。measure()
方法接收两个参数:widthMeasureSpec
和 heightMeasureSpec
,这两个值分别用于确定视图的宽度、高度的规格和大小。
MeasureSpec
的值由 specSize
和 specMode
共同组成,其中 specSize
记录的是大小,specMode
记录的是规格。在支持 match_parent、具体宽高值之前,我们需要了解 specMode
的 3 种类型:
模式类型 | 说明 |
---|---|
EXACTLY | 表示父视图希望子视图的大小应该是由 specSize 的值来决定的,系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。match_parent、具体的数值(如 100dp)对应的都是这个模式。 |
AT_MOST | 表示子视图最多只能是 specSize 中指定的大小,开发人员应该尽可能小地去设置这个视图,并且保证不会超过 specSize。系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。一般来说 wrap_content 对应这种模式。 |
UNSPECIFIED | 表示开发人员可以将视图按照自己的意愿设置成任意的大小,没有任何限制。这种情况比较少见,不太会用到。 |
那么这两个 MeasureSpec 又是从哪里来的呢?其实这是从整个视图树的控制类 ViewRootImpl 中创建的,在 ViewRootImpl 的 measureHierarchy()
函数中会调用如下代码获取 MeasureSpec:
if (!goodMeasure) {
//获取MeasureSpec
childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width);
childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height);
//执行测量过程
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
if (mWidth != host.getMeasuredWidth() || mHeight != host.getMeasuredHeight()) {
windowSizeMayChange = true;
}
}
从上述代码中可以看到,这里调用了 getRootMeasureSpec()
方法来获取 widthMeasureSpec
和 heightMeasureSpec
的值。注意,方法中传入的参数,参数 1 为窗口的宽度或者高度,而 lp.width
和 lp.height
在创建 ViewGroup 实例时就被赋值了,它们都等于 MATCH_PARENT
。然后看一下 getRootMeasureSpec()
方法的源码:
/**
* Figures out the measure spec for the root view in a window based on it's
* layout params.
*
* @param windowSize
* The available width or height of the window
*
* @param rootDimension
* The layout params for one dimension (width or height) of the
* window.
*
* @return The measure spec to use to measure the root view.
*/
private static int getRootMeasureSpec(int windowSize, int rootDimension) {
int measureSpec;
switch (rootDimension) {
case ViewGroup.LayoutParams.MATCH_PARENT:
// Window can't resize. Force root view to be windowSize.
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
break;
case ViewGroup.LayoutParams.WRAP_CONTENT:
// Window can resize. Set max size for root view.
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
break;
default:
// Window wants to be an exact size. Force root view to be that size.
measureSpec = MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);
break;
}
return measureSpec;
}
从上述程序中可以看到,这里使用了 MeasureSpec.makeMeasureSpec()
方法来组装一个 MeasureSpec。
当 rootDimension 参数等于
MATCH_PARENT
时,MeasureSpec 的 specMode 就等于EXACTLY
;当 rootDimension 等于
WRAP_CONTENT
时,MeasureSpec 的 specMode 就等于AT_MOST
,并且MATCH_PARENT
和WRAP_CONTENT
的 specSize 都是等于windowSize
,也就意味着根视图总是会充满全屏的。如果两者都不是,那就按照开发者自定义的大小。
当构建完根视图的 MeasureSpec 之后就会执行 performMeasure()
函数从根视图开始一层一层测量视图的大小。最终会调用每个 View 的 onMeasure()
函数,在该函数中用户需要根据 MeasureSpec 测量 View 的大小,最终调用 setMeasureDimension()
函数设置该视图的大小。下面我们看看 SimpleImageView 根据 MeasureSpec 设置大小的实现,代码如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//获取宽度的模式与大小
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int width = MeasureSpec.getSize(widthMeasureSpec);
//获取高度的模式与大小
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
// 设置View的宽高
setMeasuredDimension(measureWidth(widthMode, width), measureHeight(heightMode, height));
}
private int measureWidth(int mode, int width) {
switch (mode) {
case MeasureSpec.UNSPECIFIED:
Log.e(VIEW_LOG_TAG, "### MeasureSpec.AT_MOST");
break;
case MeasureSpec.AT_MOST:
Log.e(VIEW_LOG_TAG, "### MeasureSpec.AT_MOST");
break;
case MeasureSpec.EXACTLY:
Log.e(VIEW_LOG_TAG, "### MeasureSpec.EXACTLY , width = " + width);
mWidth = width;
break;
}
return mWidth;
}
private int measureHeight(int mode, int height) {
switch (mode) {
case MeasureSpec.UNSPECIFIED:
break;
case MeasureSpec.AT_MOST:
break;
case MeasureSpec.EXACTLY:
Log.e(VIEW_LOG_TAG, "### MeasureSpec.EXACTLY , height = " + height);
mHeight = height;
break;
}
return mHeight;
}
@Override
protected void onDraw(Canvas canvas) {
if (mBitmap == null) {
mBitmap = Bitmap.createScaledBitmap(ImageUtils.drawableToBitamp(mDrawable), getMeasuredWidth(),
getMeasuredHeight(), true);
}
// 绘制图片
canvas.drawBitmap(mBitmap, getLeft(), getTop(), mBitmapPaint);
}
在 onMeasure()
函数中我们获取宽、高的模式与大小,然后分别调用 measureWidth()
、measureHeight()
函数,根据 MeasureSpec 的 mode 与 size 计算 View 的具体大小。在 MeasureSpec.UNSPECIFIED
与 MeasureSpec.AT_MOST
类型中,我们都将 View 的宽高设置为图片的宽高,而用户指定了具体的大小或 match_parent
时,它的模式则为 EXACTLY
,它的值就是 MeasureSpec 中的值。最后在绘制图片时,会根据 View 的大小重新创建一个图片,得到一个与 View 大小一致的 Bitmap,然后绘制到 View 上。三种效果如下图所示:
View 的测量是自定义 View 中最为重要的一步,如果不能正确地测量视图的大小,那么将会导致视图显示不完整等情况,这将严重影响 View 的显示效果。因此,理解 MeasureSpec 以及正确的测量方法对于开发人员来说是必不可少的。
三、Canvas 与 Paint(画布和画笔)
对于 Android 来说,整个 View 就是一个画布,也就是 Canvas。开发人员可以通过画笔 Paint 在这张画布上绘制各种各样的图形、元素,例如矩形、圆形、椭圆、文字、圆弧、图片等,通过修改画笔的属性则可以将同一个元素绘制出不同的效果,例如设置画笔的颜色为红色,那么通过该画笔绘制一个矩形时,该矩形的颜色则为红色。
Canvas 的部分重要函数如下表所示:
函数名 | 作用 |
---|---|
drawRect(Rect r, Paint paint) | 绘制一个矩形,参数 1 为 RectF 一个区域 |
drawBitmap(Bitmap bitmap, float left, float top, Paint paint) | 绘制一张图片,left 为左边起点,top 为上边起点 |
drawPath(Path path, Paint paint) | 绘制一个路径,参数 1 为 Path 路径对象 |
drawLine(float startX, float startY, float stopX, float stopY, Paint paint) | 绘制线段 |
drawText(String text, float x, float y, Paint paint) | 绘制文本 |
drawOval(RectF oval, Paint paint) | 绘制椭圆 |
drawCircle(float cx, float cy, float radius, Paint paint) | 绘制圆形,参数 1 是中心点的 x 轴,参数 2 是中心点的 y 轴,参数 3 是半径,参数 4 是 Paint 对象 |
drawArc(RectF oval, float startAngle, float sweepAngle, boolean useCenter, Paint paint) | 绘制扇形或弧形,圆形进度条就是使用这个函数不断地绘制扇形或者弧形实现 |
clipRect(int left, int top, int right, int bottom) | 裁剪画布上的一个区域,使得后续的操作只在这个区域上有效 |
save() | 存储当前矩阵和裁剪状态到一个私有的栈中。随后调用 translate,scale,rotate,skew,concat or clipRect,clipPath 等函数还是会正常执行,但是调用了 restore() 之后,这些调用产生的效果就会失效,在 save() 之前的 Canvas 状态就会被恢复。 |
restore() | 恢复到 save() 之前的状态 |
Paint 的部分重要函数如下表所示:
函数名 | 作用 |
---|---|
setARGB(int a, int r, int g, int b) | 设置绘制的颜色,a 代表透明度,r、g、b 代表颜色值 |
setColor(int color) | 设置绘制的颜色,使用颜色值来表示,该颜色值包括透明度和 RGB 颜色 |
setAntiAlias(boolean aa) | 设置是否使用抗锯齿功能,会消耗较大资源,绘制图形速度会变慢 |
setShader(Shader shader) | 设置图像效果,使用 Shader 可以绘制出各种渐变效果 |
setShadowLayer(float radius, float dx, float dy, int shadowColor) | 在图形下面设置阴影层,产生阴影效果,radius 为阴影的角度,dx 和 dy 为阴影在 x 轴和 y 轴上的距离,color 为阴影的颜色 |
setStyle(Style style) | 设置画笔的样式,为 FILL、FILL_OR_STROKE 或 STROKE。Style.FILL:实心;STROKE:空心;FILL_OR_STROKE:同时实心与空心 |
setStrokeCap(Cap cap) | 当画笔样式为 STROKE 或 FILL_OR_STROKE 时,设置笔刷的图像样式,如圆形样式:Cap.ROUND;或方形样式:Cap.SQUARE |
setStrokeWidth(float width) | 当画笔样式为 STROKE 或 FILL_OR_STROKE 时,设置笔刷的粗细度 |
setXfermode(Xfermode xfermode) | 设置图形重叠时的处理模式,如合并、取交集或并集,经常用来制作橡皮的擦除效果 |
setTextSize(float textSize) | 设置绘制文字的字号大小 |
在 onDraw()
方法中我们经常会调用 Canvas 的 save()
和 restore()
方法,这两个方法很重要,那么它们的作用是什么呢?
有时候我们需要使用 Canvas 来绘制一些特殊的效果,在做这些特殊效果之前,我们希望保存原来的 Canvas 状态,此时需要调用 Canvas 的 save()
函数。执行 save()
之后,可以调用 Canvas 的平移、放缩、旋转、skew(倾斜)、裁剪等操作,然后再进行其他的绘制操作。当绘制完毕之后,我们需要调用 restore()
函数来恢复 Canvas 之前保存的状态。Canvas 的 save()
方法和 restore()
方法要配对使用,但要注意的是,restore()
的调用次数可以比 save()
函数少,不能多,否则会引发异常。
例如,我们需要在 SimpleImageView 中绘制一个竖向的文本,而 drawText()
函数默认是横向绘制的,如果直接在 onDraw()
函数中绘制文本,看看会发生什么,代码如下:
@Override
protected void onDraw(Canvas canvas) {
if (mBitmap == null) {
mBitmap = Bitmap.createScaledBitmap(ImageUtils.drawableToBitamp(mDrawable),
getMeasuredWidth(), getMeasuredHeight(), true);
}
// 绘制图片
canvas.drawBitmap(mBitmap, getLeft(), getTop(), mBitmapPaint);
mBitmapPaint.setColor(Color.RED);
mBitmapPaint.setTextSize(40);
canvas.drawText("Roger_Federer", getLeft() + 50, getTop() + 50, mBitmapPaint);
}
效果如下图:
那我们怎么样才能实现将文字竖向显示呢?通常的思路是:在绘制文本之前将画布旋转一定的角度,使得画布的角度发生变化,此时再在画布上绘制文字,得到的效果就是文字被绘制为竖向的。代码如下:
@Override
protected void onDraw(Canvas canvas) {
if (mBitmap == null) {
mBitmap = Bitmap.createScaledBitmap(ImageUtils.drawableToBitamp(mDrawable),
getMeasuredWidth(), getMeasuredHeight(), true);
}
// 绘制图片
canvas.drawBitmap(mBitmap, getLeft(), getTop(), mBitmapPaint);
//保存画布状态
canvas.save();
//旋转90°
canvas.rotate(90);
mBitmapPaint.setColor(Color.RED);
mBitmapPaint.setTextSize(40);
//绘制文本
canvas.drawText("Roger_Federer", getLeft() + 50, getTop() - 50, mBitmapPaint);
//恢复原来的状态
canvas.restore();
}
效果如下图:
实现思路是在绘制文本之前将画布旋转 90°,即沿顺时针方向旋转 90°,然后再在画布上绘制文字,最后将画布 restore 到 save 之前的状态。
注意:
这里的旋转是指坐标轴发生旋转,而不是画布发生旋转。
rotate()
的参数:正数代表顺时针,负数代表逆时针。注意
drawText()
中 x 和 y 的偏移值,什么时候用 + ,什么时候用 -。上述代码即使没有
save()
和restore()
也能实现相同的效果,之所以save()
和restore()
是因为需要将坐标轴还原回默认的状态,防止之后的操作产生意料之外的结果。