赞
踩
自定义View、多线程、网络,被认为是Android开发者必须牢固掌握的最基础的三大基本功。而自定义View又是Android开发中特别重要的一环,很多地方都需要用到自定义View。这篇文章我就梳理一下自定义View的相关知识。
目录
什么是自定义View?自定义View,顾名思义就是现有的官方提供的View(控件)已经无法满足我们的日常看法的需要,需要我们自己去定义实现一个View。而在我们项目开发的过程中,遇到一些复杂的布局或者控件是在所难免的。因此,对于我们来说,学习好自定义View才会变得如此重要。
首先我们先了解一下View的绘制流程,它对应了View中的三个抽象方法:即onMeasure->onLayout->onDraw
其次我们应该明确,学习自定义View的三个关键点:
掌握了这三点也就基本掌握了自定义View。这里的布局是指自定义View在哪个位置显示,包括测量和布局两方面,对应了onMeasure和onLayout方法;绘制是让自定义View显示在屏幕上,对应了onDraw方法;而触摸反馈则是比较高级的概念,它对应了自定义View的行为。
最后,我们着重了解一下如何自定义View。通常有以下三种方法:
(1)自定义组合View
(2)继承系统控件或布局(系统View控件:如TextView,系统ViewGroup布局:如LinearLayout)
(3)直接继承View/ViewGroup
这种方法非常特殊,因为它不需要重写相关方法就可以直接使用,因此开发起来非常方便。但是缺点也非常明显,它只能够通过现有的View(系统View)进行组合,如果我们需要自定义的View是形状非常不规则,无法通过现有View直接组合得出的话,这种方法是无法满足要求的。
如下图,以实现一个自定义的TitleBar为例:
1. 自定义属性
在values文件夹下,新建一个attrs.xml文件,并且自定义相关属性。
- <?xml version="1.0" encoding="utf-8"?>
- <resources>
- <declare-styleable name="CusTitleBar">
- <attr name="bg_color" format="color"></attr>
- <attr name="text_color" format="color"></attr>
- <attr name="title_text" format="string"></attr>
- </declare-styleable>
- </resources>
2. 自定义布局
然后在layout文件夹,新建一个布局文件layout_custom_titlebar,并根据需要进行自定义布局。
- <?xml version="1.0" encoding="utf-8"?>
- <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="60dp"
- android:id="@+id/layout_titlebar_root">
-
- <ImageView
- android:id="@+id/btn_left"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_centerVertical="true"
- android:src="@drawable/ico_return"
- android:paddingLeft="10dp" />
-
- <TextView
- android:id="@+id/tv_title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_centerHorizontal="true"
- android:layout_centerVertical="true"
- android:textSize="20sp" />
-
- <ImageView
- android:id="@+id/btn_right"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentRight="true"
- android:layout_centerVertical="true"
- android:src="@drawable/ico_title_right"
- android:paddingRight="10dp" />
- </RelativeLayout>

3. 实现自定义View
通过继承一个系统Layout父布局,并且将自定义View的布局和属性进行关联。再根据需要,编写一些功能代码。
- public class CustomTitleBar extends RelativeLayout {
- private int mBgColor = Color.BLUE;
- private int mTextColor = Color.WHITE;
- private String mTitleText = "";
-
- private ImageView btn_left;
- private ImageView btn_right;
- private TextView tvTitle;
- private RelativeLayout relativeLayout;
-
- public CustomTitleBar(Context context) {
- super(context);
- initView(context);
- }
-
- public CustomTitleBar(Context context, AttributeSet attrs) {
- super(context, attrs);
- initTypeValue(context,attrs);
- initView(context);
- }
-
- public void initTypeValue(Context context ,AttributeSet attrs){
- TypedArray a = context.obtainStyledAttributes(attrs,R.styleable.CusTitleBar);
- mBgColor = a.getColor(R.styleable.CusTitleBar_bg_color, Color.YELLOW);
- mTitleText = a.getString(R.styleable.CusTitleBar_title_text);
- mTextColor = a.getColor(R.styleable.CusTitleBar_text_color,Color.RED);
- a.recycle();
- }
-
- public void initView(Context context){
- LayoutInflater.from(context).inflate(R.layout.layout_custom_titlebar,this,true);
-
- btn_left = findViewById(R.id.btn_left);
- btn_right = findViewById(R.id.btn_right);
- tvTitle = findViewById(R.id.tv_title);
- relativeLayout = findViewById(R.id.layout_titlebar_root);
-
- relativeLayout.setBackgroundColor(mBgColor);
- tvTitle.setTextColor(mTextColor);
- tvTitle.setText(mTitleText);
- }
-
- public void setBackClickListener(OnClickListener listener){
- btn_left.setOnClickListener(listener);
- }
-
- public void setRightClickListener(OnClickListener listener){
- btn_right.setOnClickListener(listener);
- }
-
- public void setTitleText(String str){
- if(!TextUtils.isEmpty(str)){
- tvTitle.setText(str);
- }
- }
-
- }

4. 使用自定义View
用法非常简单,在需要使用的layout布局中,将自定义的View导入,并完善相关属性。最后在Java代码中进行调用即可。
- <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:tools="http://schemas.android.com/tools"
- xmlns:apps="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical"
- android:background="#eee"
- tools:context=".MainActivity">
-
- <software.baby.learncustomview.CustomTitleBar
- android:id="@+id/custom_title_bar"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- apps:title_text="@string/app_name"
- apps:text_color="@color/colorWhite"
- apps:bg_color = "@color/colorPrimary" />
- </LinearLayout>

- public class MainActivity extends AppCompatActivity {
- private CustomTitleBar customTitleBar;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_main);
-
- customTitleBar = findViewById(R.id.custom_title_bar);
- customTitleBar.setTitleText("标题标题");
- customTitleBar.setBackClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View view) {
- Toast.makeText(MainActivity.this, "Click Back!", Toast.LENGTH_SHORT).show();
- }
- });
- customTitleBar.setRightClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View view) {
- Toast.makeText(MainActivity.this, "Click Right!", Toast.LENGTH_SHORT).show();
- }
- });
- }
- }

自定义绘制的上手非常容易:提前创建好 Paint
对象,重写 onDraw()
,把绘制代码写在 onDraw()
里面,就是自定义绘制最基本的实现。大概就像这样:
- Paint paint = new Paint();
-
- @Override
- protected void onDraw(Canvas canvas) {
- super.onDraw(canvas);
-
- // 绘制一个圆
- canvas.drawCircle(300, 300, 200, paint);
- }
指的是Canvas类下的所有 draw-
打头的方法,例如 drawCircle()
drawBitmap()
drawCircle(float centerX, float centerY, float radius, Paint paint)
该方法用于绘制圆形。前两个参数 centerX
centerY
是圆心的坐标,第三个参数 radius
是圆的半径,单位都是像素,它们共同构成了这个圆的基本信息(即用这几个信息可以构建出一个确定的圆);第四个参数 paint
提供基本信息之外的所有风格信息,例如颜色、线条粗细、阴影等。
canvas.drawCircle(300, 300, 200, paint);
drawRect(float left, float top, float right, float bottom, Paint paint)
该方法用于绘制矩形。left
, top
, right
, bottom
是矩形四条边的坐标。
canvas.drawRect(100, 100, 500, 500, paint);
drawPoint(float x, float y, Paint paint)
该方法用于绘制点。x
和 y
是点的坐标。
drawPoint(float[] pts, int offset, int count, Paint paint) / drawPoints(float[] pts, Paint paint)
该方法用于批量绘制点。pts
这个数组是点的坐标,每两个成一对;offset
表示跳过数组的前几个数再开始记坐标;count
表示一共要绘制几个点。
- float[] points = {0, 0, 50, 50, 50, 100, 100, 50, 100, 100, 150, 50, 150, 100};
- // 绘制四个点:(50, 50) (50, 100) (100, 50) (100, 100)
- canvas.drawPoints(points, 2 /* 跳过两个数,即前两个 0 */,
- 8 /* 一共绘制 8 个数(4 个点)*/, paint);
drawOval(float left, float top, float right, float bottom, Paint paint)
该方法用于绘制椭圆。只能绘制横着的或者竖着的椭圆,不能绘制斜的。left
, top
, right
, bottom
是这个椭圆的左、上、右、下四个边界点的坐标。
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)
该方法用于批量绘制线。具体参数参照批量绘制点。
- float[] points = {20, 20, 120, 20, 70, 20, 70, 120, 20, 120, 120, 120, 150, 20, 250, 20, 150, 20, 150, 120, 250, 20, 250, 120, 150, 120, 250, 120};
- canvas.drawLines(points, paint);
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
表示是否连接到圆心,如果不连接到圆心,就是弧形,如果连接到圆心,就是扇形。
- paint.setStyle(Paint.Style.FILL); // 填充模式
- canvas.drawArc(200, 100, 800, 500, -110, 100, true, paint); // 绘制扇形
- canvas.drawArc(200, 100, 800, 500, 20, 140, false, paint); // 绘制弧形
- paint.setStyle(Paint.Style.STROKE); // 画线模式
- canvas.drawArc(200, 100, 800, 500, 180, 60, false, paint); // 绘制不封口的弧形
drawPath(Path path, Paint paint)
该方法用于绘制自定义图形。drawPath(path)
这个方法是通过描述路径的方式来绘制图形的,它的 path
参数就是用来描述图形路径的对象。
- public class PathView extends View {
-
- Paint paint = new Paint();
- Path path = new Path(); // 初始化 Path 对象
-
- ......
-
- {
- path.addArc(200, 200, 400, 400, -225, 225);
- path.arcTo(400, 200, 600, 400, -180, 225, false);
- path.lineTo(400, 542);
- }
-
- @Override
- protected void onDraw(Canvas canvas) {
- super.onDraw(canvas);
-
- canvas.drawPath(path, paint); // 绘制出 path 描述的图形(心形)
- }
- }

第一类:直接描述路径
这一类方法还可以细分为两组:添加子图形和画线(直线或曲线)
第一组: addXxx()
——添加子图形
Direction 指路径的方向。路径方向有两种:顺时针 (CW
clockwise) 和逆时针 (CCW
counter-clockwise) 。
path.addCircle(300, 300, 200, Path.Direction.CW);
第二组:xxxTo()
——画线(直线或曲线)
和第一组 addXxx()
方法的区别在于,第一组是添加的完整封闭图形(除了 addPath()
),而这一组添加的只是一条线。
从当前位置向目标位置画一条直线, x
和 y
是目标位置的坐标。这两个方法的区别是,lineTo(x, y)
的参数是绝对坐标,而 rLineTo(x, y)
的参数是相对当前位置的相对坐标 (前缀 r
指的就是 relatively
「相对地」)。
close() 封闭当前子图形。
- paint.setStyle(Style.STROKE);
- path.moveTo(100, 100);
- path.lineTo(200, 100);
- path.lineTo(150, 150);
- path.close(); // 使用 close() 封闭子图形。等价于 path.lineTo(100, 100)
不是所有的子图形都需要使用 close()
来封闭。当需要填充图形时(即 Paint.Style
为 FILL
或 FILL_AND_STROKE
),Path
会自动封闭子图形。
第二类:辅助的设置或计算
Path.setFillType(Path.FillType ft) 设置填充方式
方法中填入不同的 FillType
值,就会有不同的填充效果。FillType
的取值有四个:
EVEN_ODD
WINDING
(默认值)INVERSE_EVEN_ODD
INVERSE_WINDING
drawBitmap(Bitmap bitmap, float left, float top, Paint paint)
该方法用于绘制Bitmap对象。其中 left
和 top
是要把 bitmap
绘制到的位置坐标。
drawText(String text, float x, float y, Paint paint)
该方法用于绘制文字。参数 text
是用来绘制的字符串,x
和 y
是绘制的起点坐标。
Paint
类的几个最常用的方法。具体是:
Paint.setStyle(Style style)
设置绘制模式
Style
具体来说有三种: FILL
, STROKE
和 FILL_AND_STROKE
。FILL
是填充模式,STROKE
是画线模式(即勾边模式),FILL_AND_STROKE
是两种模式一并使用:既画线又填充。它的默认值是 FILL
,填充模式。
- paint.setStyle(Paint.Style.STROKE); // Style 修改为画线模式
- canvas.drawCircle(300, 300, 200, paint);
Paint.setColor(int color)
设置颜色
paint.setColor(Color.RED);
Paint.setColorFilter(ColorFilter colorFilter)
设置颜色过滤
颜色过滤的意思,就是为绘制的内容设置一个统一的过滤策略。ColorFilter
并不直接使用,而是使用它的子类。它共有三个子类:
LightingColorFilter
模拟简单的光照效果PorterDuffColorFilter
指定的颜色和一种指定的 PorterDuff.Mode
来与绘制对象进行合成ColorMatrixColorFilter
使用一个 ColorMatrix
来对颜色进行处理- ColorFilter lightingColorFilter = new LightingColorFilter(0xffffff, 0x003000);
- paint.setColorFilter(lightingColorFilter);
Paint.setStrokeWidth(float width)
设置线条宽度
Paint.setTextSize(float textSize)
设置文字大小
Paint.setShader(Shader shader)
设置 Shader
Shader 这个英文单词很多人没有见过,它的中文叫做「着色器」,也是用于设置绘制颜色的。「着色器」不是 Android 独有的,它是图形领域里一个通用的概念,它和直接设置颜色的区别是,着色器设置的是一个颜色方案,或者说是一套着色规则。当设置了 Shader
之后,Paint
在绘制图形和文字时就不使用 setColor/ARGB()
设置的颜色了,而是使用 Shader
的方案中的颜色。
Android 的绘制里使用 Shader
,并不直接用 Shader
这个类,而是用它的几个子类。具体来讲有
LinearGradient
线性渐变RadialGradient
辐射渐变SweepGradient
扫描渐变BitmapShader
用 Bitmap
的像素来作为图形或文字的填充ComposeShader
混合着色器- Shader shader = new LinearGradient(100, 100, 500, 500, Color.parseColor("#E91E63"),
- Color.parseColor("#2196F3"), Shader.TileMode.CLAMP);
- paint.setShader(shader);
-
- ...
-
- canvas.drawCircle(300, 300, 200, paint);
Paint.setAntiAlias(boolean aa)
设置抗锯齿开关
在绘制的时候,往往需要开启抗锯齿来让图形和文字的边缘更加平滑。开启抗锯齿很简单,只要在 new Paint()
的时候加上一个 ANTI_ALIAS_FLAG
参数就行。
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
另外,你也可以使用 Paint.setAntiAlias(boolean aa)
来动态开关抗锯齿。效果如下:
范围裁切有两个方法: clipRect()
和 clipPath()
clipRect() 按矩形区域裁切
记得要加上 Canvas.save()
和 Canvas.restore()
来及时恢复绘制范围。
- canvas.save();
- canvas.clipRect(left, top, right, bottom);
- canvas.drawBitmap(bitmap, x, y, paint);
- canvas.restore();
clipPath() 按路径裁切
和 clipRect() 用法完全一样,只是把参数换成了 Path。
- canvas.save();
- canvas.clipPath(path1);
- canvas.drawBitmap(bitmap, point1.x, point1.y, paint);
- canvas.restore();
1)使用 Canvas
来做常见的二维变换
- canvas.save();
- canvas.translate(200, 0);
- canvas.drawBitmap(bitmap, x, y, paint);
- canvas.restore();
2)使用 Matrix
来做常见和不常见的二维变换
i. 使用 Matrix 来做常见变换
Matrix
做常见变换的方式:
Matrix
对象;Matrix
的 pre/postTranslate/Rotate/Scale/Skew()
方法来设置几何变换;Canvas.setMatrix(matrix)
或 Canvas.concat(matrix)
来把几何变换应用到 Canvas
。 Matrix
应用到 Canvas
有两个方法:
Canvas.setMatrix(matrix)
用 Matrix
直接替换 Canvas
当前的变换矩阵,即抛弃 Canvas
当前的变换,改用 Matrix
的变换Canvas.concat(matrix)
用 Canvas
当前的变换矩阵和 Matrix
相乘,即基于 Canvas
当前的变换,叠加上 Matrix
中的变换- Matrix matrix = new Matrix();
-
- ...
-
- matrix.reset();
- matrix.postTranslate();
- matrix.postRotate();
-
- canvas.save();
- canvas.concat(matrix);
- canvas.drawBitmap(bitmap, x, y, paint);
- canvas.restore();
ii. 使用 Matrix 来做自定义变换
Matrix
的自定义变换使用的是 setPolyToPoly()
方法。
- Matrix matrix = new Matrix();
- float pointsSrc = {left, top, right, top, left, bottom, right, bottom};
- float pointsDst = {left - 10, top + 50, right + 120, top - 90, left + 20, bottom + 30, right + 20, bottom + 60};
-
- ...
-
- matrix.reset();
- matrix.setPolyToPoly(pointsSrc, 0, pointsDst, 0, 4);
-
- canvas.save();
- canvas.concat(matrix);
- canvas.drawBitmap(bitmap, x, y, paint);
- canvas.restore();
3)使用 Camera
来做三维变换
Camera
的三维变换有三类:旋转、平移、移动相机。
- canvas.save();
-
- camera.rotateX(30); // 旋转 Camera 的三维空间
- camera.applyToCanvas(canvas); // 把旋转投影到 Canvas
-
- canvas.drawBitmap(bitmap, point1.x, point1.y, paint);
- canvas.restore();
使用不同的绘制方法,以及在重写的时候把绘制代码放在 super.绘制方法()
的上面或下面不同的位置,以此来实现需要的遮盖关系。
注意事项:
ViewGroup
的子类中重写除 dispatchDraw()
以外的绘制方法时,可能需要调用 setWillNotDraw(false)
;onDraw()
。自定义View的布局实际上分为两个阶段,测量阶段和布局阶段。其具体的流程如下:
在这个过程中,我们需要重写布局过程的相关方法:
对于重写过程大致上又可以分为三类:
1. 重写onMeasure()来修改已有View的尺寸
- public class SquareImageView extends ImageView {
-
- ...
-
- @Override
- protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
- //先执行原测量算法
- super.onMeasure(widthMeasureSpec, heightMeasureSpec);
- //获取原先的测量结果
- int measuredWidth = getMeasuredWidth();
- int measuredHeight = getMeasuredHeight();
-
- //利用原先的测量结果计算出新尺寸
- if (measuredWidth > measuredHeight) {
- measuredWidth = measuredHeight;
- } else {
- measuredHeight = measuredWidth;
- }
- //保存计算后的结果
- setMeasuredDimension(measuredWidth, measuredHeight);
- }
- }

2. 重写onMeasure()来全新计算自定义View的尺寸
- public class CustomView extends View {
-
- ...
-
- @Override
- protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
-
- measuredWidth = ...;
- measuredHeight = ...;
-
- measuredWidth = resolveSize(measuredWidth, widthMeasureSpec);
- measuredHeight = resolveSize(measuredHeight, heightMeasureSpec);
-
- setMeasuredDimension(measuredWidth, measuredHeight);
- }
- }

3. 重写onMeasure()和onLayout()来全新计算自定义View的内部布局
(1) 重写onMeasure()来计算内部布局
关键:1)宽度和高度的MeasureSpec的计算
2)结合开发者的要求(layout_xxx)和自己地可用空间(自己的尺寸上限 - 已用空间)
难点:【可用空间】的获得
- @Override
- protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
- for (int i = 0; i < getChildCount(); i++) {
- View childView = getChildAt(i);
-
- //子View的尺寸限制
- int childWidthSpec;
-
- //已使用的Width
- int usedWidth = ...;
- int selfWidthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
- int selfWidthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
- LayoutParams lp = childView.getLayoutParams();
- switch (lp.width) {
- case LayoutParams.MATCH_PARENT: //子View填满父布局(可用空间)
- if (selfWidthSpecMode == MeasureSpec.EXACTLY || selfWidthSpecMode == MeasureSpec.AT_MOST) {
- childWidthSpec = MeasureSpec.makeMeasureSpec(selfWidthSpecSize - usedWidth, MeasureSpec.EXACTLY);
- } else {
- //由于无上限的可用空间无法顶满
- childWidthSpec = MeasureSpec.makeMeasureSpec(0 ,MeasureSpec.UNSPECIFIED);
- }
- break;
- case LayoutParams.WRAP_CONTENT: //子View自适应,隐含条件不能超过父布局(可用空间)
- if (selfWidthSpecMode == MeasureSpec.EXACTLY || selfWidthSpecMode == MeasureSpec.AT_MOST) {
- childWidthSpec = MeasureSpec.makeMeasureSpec(selfWidthSpecSize - usedWidth, MeasureSpec.AT_MOST);
- } else {
- //不用考虑额外的限制
- childWidthSpec = MeasureSpec.makeMeasureSpec(0 ,MeasureSpec.UNSPECIFIED);
- }
- break;
- default: //具体的值
- childWidthSpec = MeasureSpec.makeMeasureSpec(lp.width, MeasureSpec.EXACTLY);
- break;
- }
-
- childView.measure(childWidthSpec, childHeightSpec);
- }
- }

(2)重写onLayout()来摆放子View
- @Override
- protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
- for (int i = 0; i < getChildCount(); i++) {
- View childView = getChildAt(i);
- childView.layout(childLeft[i], childTop[i], childRight[i], childBottom[i]);
- }
- }
自定义触摸反馈的关键:
onTouchEvent()
,在里面写上你的触摸反馈算法,并返回 true
(关键是 ACTION_DOWN
事件时返回 true
)。ViewGroup
,还需要重写 onInterceptTouchEvent()
,在事件流开始时返回 false
,并在确认接管事件流时返回一次 true
,以实现对事件的拦截。requestDisallowInterceptTouchEvent()
,通知父 View 在当前事件流中不再尝试通过 onInterceptTouchEvent()
来拦截。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。