安卓开发如何实现自定义View
自定义View
三种常见自定义控件方式
| 方式 | 特点 | 举例 |
|---|---|---|
| ① 继承现有控件 | 改造已有控件 | 圆角 ImageView、自定义 Button、带图标的 TextView |
| ② 组合控件 | 在 XML 里组合多个控件 | 搜索框、标题栏 |
| ③ 完全自绘控件 | 自己绘制(继承 View) | 进度条、雷达图、波浪图等 |
一、继承现有控件
继承已有控件(如 TextView、ImageView)后改造外观或逻辑,下面重写一个给文字自带下划线的TextView为例演示一下如何继承控件。
1.继承对应的控件类
改造VIew需要继承重写类并根据需要写对应VIew的属性集(AttributeSet)
下面是继承TextView类的控件,为这个控件写一个类,构造参数必须要有context和attrs,Android 系统在解析 XML 时,会自动调用自定义控件的「双参数构造函数」(context: Context, attrs: AttributeSet),并传入这两个必要参数
下面的AttributeSet 是一个接口,它直接代表了 XML 布局文件中为某个 View 定义的所有属性的原始集合。TypedArray 是一个封装类,它对 AttributeSet 进行了高级处理,能够自动解析资源引用并返回对应的实际值。
class UnderlineTextView @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {private var showUnderline: Boolean = trueinit {// 读取自定义属性attrs?.let {val typedArray = context.obtainStyledAttributes(it, R.styleable.UnderlineTextView)//解析 AttributeSet (it 即指代 attrs),并返回一个 TypedArray 对象。showUnderline = typedArray.getBoolean(R.styleable.UnderlineTextView_showUnderline, true)typedArray.recycle()//TypedArray 是一个共享资源,使用完毕后必须调用 recycle() 方法来将其回收。}applyUnderline()}private fun applyUnderline() {paint.flags = if (showUnderline) {Paint.UNDERLINE_TEXT_FLAG or Paint.ANTI_ALIAS_FLAG//表示同时启用两者,一个是抗锯齿,一个是下划线} else {Paint.ANTI_ALIAS_FLAG}invalidate() // 通知重绘}fun setShowUnderline(show: Boolean) {showUnderline = showapplyUnderline()}
}
Paint 是 Android 2D 绘图(如自定义 View 绘制文字、图形)的核心类,用于定义绘制的样式(颜色、字体、线条粗细、特效等),相当于现实中的 “画笔” 。
flags 是 Paint 的一个整数属性,用于通过位运算(or/and 等)设置多个绘制标志(flag),每个标志代表一种绘制特性。例如:
Paint.UNDERLINE_TEXT_FLAG:文字下划线标志(为文字添加下划线)。Paint.ANTI_ALIAS_FLAG:抗锯齿标志(让绘制的图形 / 文字边缘更平滑,减少锯齿感)。
invalidate()是View类方法,用于触发当前 View 的重绘。
2.自定义属性
自定义属性需要单独写一个文件,一般放在res/values目录下,文件名命名为attrs表明是自定义属性集,这个属性就是在xml文件中进行设置的属性,类似TextView的textColor,textSize这种属性
<resources><declare-styleable name="UnderlineTextView"><attr name="showUnderline" format="boolean" /></declare-styleable>
</resources>
3.布局文件中使用自定义控件
在布局文件中使用和正常使用控件几乎没区别,不过注意自定义控件要写对应的全限定名,app:showUnderline="true"就是我们自定义的属性了
<LinearLayoutxmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"android:orientation="vertical"android:gravity="center"android:layout_width="match_parent"android:layout_height="match_parent"><com.example.customview.UnderlineTextViewandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:text="这段文字有下划线"android:textSize="18sp"android:textColor="#000000"app:showUnderline="true"/><com.example.customview.UnderlineTextViewandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:text="这段文字没有下划线"android:textSize="18sp"android:textColor="#000000"app:showUnderline="false"/>
</LinearLayout>
二、组合控件
组合控件相对比较简单,我们先写好想要组合的控件xml文件,这就是正常定义一个xml文件
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:tools="http://schemas.android.com/tools"android:layout_width="match_parent"android:layout_height="match_parent"xmlns:app="http://schemas.android.com/apk/res-auto"><TextViewandroid:id="@+id/textView"android:layout_width="match_parent"app:layout_constraintStart_toStartOf="parent"app:layout_constraintEnd_toEndOf="parent"android:layout_marginStart="80dp"android:layout_marginEnd="80dp"android:layout_height="80dp"android:gravity="center"android:textSize="25sp"app:layout_constraintTop_toTopOf="parent"android:text="标题栏" /><ImageViewandroid:id="@+id/imageView"android:src="@drawable/ic_launcher_foreground"android:layout_width="wrap_content"android:layout_height="wrap_content"android:maxWidth="80dp"android:maxHeight="80dp"android:adjustViewBounds="true"app:layout_constraintTop_toTopOf="parent"app:layout_constraintStart_toStartOf="parent"/><ImageViewandroid:id="@+id/imageView2"android:layout_width="wrap_content"android:layout_height="wrap_content"app:srcCompat="@drawable/ic_launcher_foreground"android:maxWidth="80dp"android:maxHeight="80dp"android:adjustViewBounds="true"app:layout_constraintTop_toTopOf="parent"app:layout_constraintStart_toEndOf="@id/textView" /></androidx.constraintlayout.widget.ConstraintLayout>
然后写一个类继承一个布局(不必和组合控件的布局相同),在构造器里面填充inflate该界面,如果想在活动里动态设置按钮的事件可以像下面一样写一个回调方法,如果想要避免反复写相同的逻辑,可以直接在初始化代码块中写好想要的监听事件。
class TitleLayout@JvmOverloads constructor(context : Context, attrs : AttributeSet? = null, defAttr : Int = 0) : LinearLayout(context, attrs, defAttr) {lateinit var exitImage: ImageViewlateinit var toastImage: ImageViewinit {val view = LayoutInflater.from(context).inflate(R.layout.title_layout, this)exitImage = view.findViewById<ImageView>(R.id.imageView)toastImage = view.findViewById<ImageView>(R.id.imageView2)}fun onExit (listener : OnClickListener) {exitImage.setOnClickListener(listener)}fun onToast(listener: OnClickListener) {toastImage.setOnClickListener(listener)}
}
做好之后就正常在布局文件中使用了
<com.example.myview.TitleLayoutandroid:id="@+id/title"android:layout_width="match_parent"android:layout_height="wrap_content"app:layout_constraintTop_toTopOf="parent"/>
三、自绘控件
自定义VIew的重头戏还在于自绘VIew,可以用来实现一些不规则的效果,这种方式最灵活也最麻烦,需要我们直接在一个画布上绘制我们想要的ui,因此需要重写onDraw()方法。在自绘控件情况下,我们需要考虑到该控件的wrap_content模式或者其他更复杂的需求,因此需要重写onMeasure()。下面以一个我们常见的ui的例子写起—天气预报中显示紫外线强度的线性彩虹条。

linePaint和circlePaint分别是画彩虹条和指示点的画笔, style = Paint.Style.STROKE,style = Paint.Style.FILL分别是两种不同的绘制样式,前者是只描边,后者是将图形填充完全,由于彩虹条本来只是一条线,因此描边就够了。而指示点我们这里需要一个实心的因此用了FILL。 onSizeChanged
LinearGradient 是一个着色器(Shader)对象,它定义了颜色如何在一个区域内进行线性过渡,也就是实现上面的渐变效果。setShader则是给我们的画笔设置这个着色器对象,使画笔画出来的对象实现对应的渐变效果。最后让我们的画笔在画布上绘画即可 canvas.drawLine(0f, centerY, w, centerY, linePaint),canvas.drawCircle(x, centerY, circleRadius, circlePaint)。
onSizeChanged()方法会在尺寸变化时调用,在尺寸发生变化时(比如旋转了设备或者代码中动态设置了尺寸),我们就需要重新给彩虹条定位。
dp方法是为了适应不同设备的分辨率,否则直接使用dx单位可能因为手机分辨率不同造成更大的差异,这个直接套用即可。
class RainbowBar @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {style = Paint.Style.STROKEstrokeWidth = dp(6f) // 使用 dpstrokeCap = Paint.Cap.BUTT//彩虹条端点设置为直线,可以用ROUND设置为圆形}private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {style = Paint.Style.FILLcolor = Color.parseColor("#FFFFFFFF")}private var gradient: LinearGradient? = nullprivate var dataPoints: FloatArray = floatArrayOf(0.2f)private val circleRadius = dp(7f)init {}override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {super.onSizeChanged(w, h, oldw, oldh)if (w > 0 && h > 0) {gradient = LinearGradient(0f, h / 2f, w.toFloat(), h / 2f,intArrayOf(Color.parseColor("#009FFB"), Color.parseColor("#55D2CC"), Color.parseColor("#FBD449"), Color.parseColor("#FEA736"), Color.parseColor("#FE3F4F") ),null,Shader.TileMode.CLAMP)linePaint.shader = gradient}}override fun onDraw(canvas: Canvas) {super.onDraw(canvas)if (width == 0 || height == 0) returnval w = width.toFloat()val centerY = height / 2fif (gradient == null) {gradient = LinearGradient(0f, centerY, w, centerY,intArrayOf(Color.parseColor("#009FFB"),Color.parseColor("#55D2CC"),Color.parseColor("#FBD449"),Color.parseColor("#FEA736"),Color.parseColor("#FE3F4F")),null,Shader.TileMode.CLAMP)linePaint.shader = gradient}canvas.drawLine(0f, centerY, w, centerY, linePaint)for (v in dataPoints) {val clamped = v.coerceIn(0f, 1f)val x = clamped * wcanvas.drawCircle(x, centerY, circleRadius, circlePaint)}}fun setDataPoints(points: FloatArray) {dataPoints = points.map { (it/10f).coerceIn(0f, 1f) }.toFloatArray()invalidate()}private fun dp(v: Float): Float =TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, resources.displayMetrics)
}
这样就基本完成了我们的彩虹条,可以直接在布局文件中使用了。
<com.example.weatherforecast.RainbowBarandroid:id="@+id/intensity_bar"android:layout_width="match_parent"android:layout_height="20dp"app:layout_constraintStart_toStartOf="parent"app:layout_constraintEnd_toEndOf="parent"android:layout_marginStart="20dp"android:layout_marginEnd="20dp"app:layout_constraintTop_toBottomOf="@id/intensity_content"/>
