自定义 View 通常分为三类:组合控件、继承控件和重绘控件。
类型 | 定义 |
---|---|
组合控件 | 基于已有系统控件,通过布局文件或代码组合得到的新控件,适合快速复用。 |
继承控件 | 通过继承系统控件类,保留或扩展其功能,适合对单一控件进行定制化。 |
重绘控件 | 直接继承自 View ,依靠 Canvas 与 Paint 完全自绘,灵活性最高。 |
组合控件
组合控件的实现思路是:将多个现有系统控件组织到一个布局文件中,再通过继承布局类(如 FrameLayout
、LinearLayout
等)包装为一个独立的自定义控件。这样可以对内部控件的样式和交互进行统一管理。
XML 布局示例:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FEFEFE">
<Button
android:id="@+id/button_left"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:backgroundTint="@android:color/transparent"
android:text="Button"
android:textColor="@color/black" />
<TextView
android:id="@+id/title_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:gravity="center"
android:text="TextView"
android:textColor="@color/black" />
</LinearLayout>
public class TitleView extends FrameLayout {
private Button leftButton;
private TextView titleText;
public TitleView(Context context, AttributeSet attrs) {
super(context, attrs);
LayoutInflater.from(context).inflate(R.layout.title, this);
titleText = findViewById(R.id.title_text);
leftButton = findViewById(R.id.button_left);
}
public void setTitleText(String text) {
titleText.setText(text);
}
public void setLeftButtonText(String text) {
leftButton.setText(text);
}
public void setLeftButtonListener(OnClickListener listener) {
leftButton.setOnClickListener(listener);
}
}
在使用时,只需要像调用系统控件一样,在 XML 中通过包名+类名引入:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.example.android.TitleView
android:id="@+id/title_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</FrameLayout>
自定义属性
系统控件的属性分为 android:
与 app:
两类,开发者也可以通过定义 declare-styleable
来添加属于自定义控件的 app:
属性。
定义属性
res/values/attrs.xml
<resources>
<declare-styleable name="TitleView">
<attr name="titleText" format="string" />
<attr name="leftButtonText" format="string" />
<attr name="titleColor" format="color" />
</declare-styleable>
</resources>
declare-styleable
的 name
对应控件类名,内部的 <attr>
用于声明属性名和可接受的数据类型。
使用属性
在布局根节点引入命名空间:
xmlns:app="http://schemas.android.com/apk/res-auto"
命名空间的 schemas.android.com 作为占位符用于放置 res-auto 自动检测的包名,所以想要引入 app:
作用域,在根布局加入固定语句即可,然后在自定义控件中使用:
<com.example.myapp.TitleView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:titleText="首页"
app:leftButtonText="返回"
app:titleColor="@color/black"/>
解析属性
在控件的构造方法中使用 TypedArray
解析属性值:
public class TitleView extends FrameLayout {
private Button leftButton;
private TextView titleText;
public TitleView(Context context, AttributeSet attrs) {
super(context, attrs);
LayoutInflater.from(context).inflate(R.layout.title, this);
titleText = findViewById(R.id.title_text);
leftButton = findViewById(R.id.button_left);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.TitleView);
String textTitle = ta.getString(R.styleable.TitleView_titleText);
String textLeftButton = ta.getString(R.styleable.TitleView_leftButtonText);
int color = ta.getColor(R.styleable.TitleView_titleColor, Color.BLACK);
ta.recycle(); // 回收TypedArray的方法,避免内存泄漏
titleText.setText(textTitle);
leftButton.setText(textLeftButton);
titleText.setTextColor(color);
}
}
因为控件类实例化时调用构造方法来读取 XML 的属性资源,所以这种方式称为 静态声明;而在 Java 代码中通过调用方法设置属性称为 动态声明。
继承控件
继承控件通过扩展现有控件类,在保留其基础功能的同时,加入额外逻辑。例如通过重写 onDraw
,在 TextView
上绘制辅助线。
关键回调方法:
- 构造方法:初始化对象并读取 XML 属性
onMeasure(int widthMeasureSpec, int heightMeasureSpec)
:确定控件尺寸onSizeChanged(int w, int h, int old, int oldh)
:控件大小首次确定或发生变化时触发onDraw(Canvas canvas)
:实际的绘制逻辑onLayout(boolean changed, int left, int top, int right, int bottom)
:View 内用于确定控件本身在父布局中的位置;ViewGroup 则用于计算和设置所有子控件的位置
因为大多数情况下我们使用继承控件的形式都是希望复用系统的 onMeasure 和 onLayout 流程,所以我们只需要重写 onDraw 方法。实现非常简单:
public class LineTextView extends androidx.appcompat.widget.AppCompatTextView {
private Paint mPaint;
public LineTextView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
mPaint = new Paint();
mPaint.setColor(Color.BLACK);
}
@Override
protected void onDraw(Canvas canvas) {
int width = getWidth();
int height = getHeight();
// 背景
mPaint.setColor(Color.parseColor("#FEFEFE"));
RectF rectF = new RectF(0, 0, width, height);
canvas.drawRect(rectF, mPaint);
// 中线
mPaint.setColor(Color.BLACK);
mPaint.setStrokeWidth(5);
canvas.drawLine(0, height / 2, width, height / 2, mPaint);
super.onDraw(canvas); // 保持原有文字绘制
}
}
重绘控件
重绘控件是完全基于 View
的高定制化自定义控件实现,适合从零绘制特殊效果。相比继承控件,它需要开发者自己实现 onMeasure
和 onDraw
,从而精确控制尺寸和外观。
测量逻辑
MeasureSpec
提供三种模式:
UNSPECIFIED
:父容器对子控件大小不做限制EXACTLY
:精确值(对应match_parent
或固定值)AT_MOST
:最大值(对应wrap_content
)
默认实现 getDefaultSize
中,AT_MOST
和 EXACTLY
被同等处理,这就是为什么如果不重写 onMeasure
,wrap_content
会表现为填满父容器,所以我们重绘控件时必须要重写 onMeasure
方法。
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpe);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize; // here
break;
}
return result;
}
示例:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(300, 300);
} else if (widthMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(300, heightSize);
} else if (heightMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(widthSize, 300);
}
}
绘制逻辑
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int paddingLeft = getPaddingLeft();
int paddingRight = getPaddingRight();
int paddingTop = getPaddingTop();
int paddingBottom = getPaddingBottom();
int width = getWidth() - paddingLeft - paddingRight;
int height = getHeight() - paddingTop - paddingBottom;
paint.setColor(Color.parseColor("#FEFEFE"));
canvas.drawRect(paddingLeft, paddingTop, width + paddingLeft, height + paddingTop, paint);
}
这样我们得到了一个矩形空白内容的控件。
渐变程度条实践
在天气应用中,用于表示指数程度的控件往往需要比系统控件更灵活。以下是一个重绘控件示例:
public class RainbowLineView extends View {
private Paint linePaint;
private Paint circlePaint;
private float[] dataPoints = {0.2f};
public RainbowLineView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
linePaint.setStyle(Paint.Style.STROKE);
linePaint.setStrokeWidth(13);
circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
circlePaint.setStyle(Paint.Style.FILL);
circlePaint.setColor(Color.parseColor("#FEFEFE"));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int centerY = getHeight() / 2;
LinearGradient gradient = new LinearGradient( // 颜色渐变过渡器
0, centerY, width, centerY,
new int[]{
Color.parseColor("#009FFB"), // 蓝
Color.parseColor("#55D2CC"), // 青
Color.parseColor("#FBD449"), // 黄
Color.parseColor("#FEA736"), // 橙
Color.parseColor("#FE3F4F") // 红
},
null, Shader.TileMode.CLAMP
);
linePaint.setShader(gradient);
// 渐变线
canvas.drawLine(0, centerY, width, centerY, linePaint);
// 标记点
for (float value : dataPoints) {
float x = value * width;
canvas.drawCircle(x, centerY, 15, circlePaint);
}
}
public void setDataPoints(float[] dataPoints) {
this.dataPoints = dataPoints;
invalidate(); // 请求重绘,回调onDraw方法
}
}
该控件通过 LinearGradient
绘制水平渐变色条,并在 dataPoints
指定的位置绘制圆点,可以用于表示某一指标在区间中的程度。