常用 View 组件大阅兵与自定义 View 初体验
熟练使用系统控件,入门自定义 View,实现个性化 UI
哈喽,各位坚持学习的小伙伴们!上一篇我们掌握了权限处理与相机相册调用,现在 App 已经能和系统硬件打交道了。但任何 App 的界面都是由一个个 View 拼起来的 —— 输入框、复选框、进度条、图片……系统自带的控件用好了能解决 80% 的需求,剩下的 20% 就需要自定义 View 来打造独一无二的视觉效果。
今天,我们就来一场「View 组件大阅兵」,快速过一遍常用控件的使用技巧,再学习布局优化、触摸事件机制,最后动手写一个自定义圆形进度条,做一个闹钟设置界面的综合案例。
全程附 Kotlin 代码 + 避坑指南,我们直接开始!
一、常用核心控件全解析
Android 系统提供了丰富的原生控件,熟练掌握它们的属性与监听逻辑,是开发复杂 UI 的基础。
1.1 EditText —— 可输入文本框
EditText 是表单、登录页、搜索页的核心控件。
核心常用属性:
| 属性 | 作用 |
|---|---|
| hint | 输入提示文字,无输入时显示 |
| inputType | 输入类型:textPassword、number、textEmailAddress 等 |
| maxLength | 最大输入字符数 |
| maxLines | 最大显示行数 |
| drawableLeft/Right | 在文字四周添加图标 |
基础使用示例:
<EditText
android:id="@+id/et_account"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="请输入手机号"
android:inputType="number"
android:maxLength="11"
android:padding="12dp"
android:textSize="16sp" />
技巧:设置 background="@null" 可去掉默认下划线,配合自定义 shape 实现个性化边框。
Kotlin 中通过 TextWatcher 实现实时监听:
etAccount.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
// 实时校验,如手机号满 11 位启用按钮
btnLogin.isEnabled = (s?.length ?: 0) == 11
}
override fun afterTextChanged(s: Editable?) {}
})
1.2 CheckBox 与 RadioGroup —— 多选与单选
CheckBox:多选框,用于同意协议、兴趣标签等场景。
<CheckBox
android:id="@+id/cb_agree"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="我已阅读并同意用户协议"
android:checked="false" />
cbAgree.setOnCheckedChangeListener { _, isChecked ->
btnSubmit.isEnabled = isChecked
}
RadioGroup + RadioButton:单选组,RadioButton 必须放在 RadioGroup 中才能实现互斥。
<RadioGroup
android:id="@+id/rg_gender"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<RadioButton android:id="@+id/rb_male" android:text="男" android:checked="true" />
<RadioButton android:id="@+id/rb_female" android:text="女" android:layout_marginStart="24dp" />
</RadioGroup>
rgGender.setOnCheckedChangeListener { _, checkedId ->
val gender = when (checkedId) {
R.id.rb_male -> "男"
R.id.rb_female -> "女"
else -> "未知"
}
}
注意:每个 RadioButton 必须设置独立 id,否则回调无法区分选中项。
1.3 SeekBar —— 拖动进度条
SeekBar 是可交互的进度条,常用于音量、亮度调节。
<SeekBar
android:id="@+id/seekbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="100"
android:progress="50" />
seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
tvVolume.text = "音量:$progress" // fromUser 区分用户拖动还是代码设置
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
1.4 ProgressBar —— 进度指示器
用于展示加载、下载等进度状态。
<!-- 圆形转圈(不确定进度) -->
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<!-- 水平进度条(确定进度) -->
<ProgressBar
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="100"
android:progress="30"
android:secondaryProgress="60" />
progressBar.progress = 65
progressBar.visibility = View.GONE // 加载完成隐藏
二、布局优化三剑客:include / merge / ViewStub
复杂页面往往存在布局层级深、重复布局多的问题,过度绘制和深层嵌套会导致渲染卡顿。
2.1 include —— 布局复用
将公共布局(如标题栏)抽取为独立文件,在多个页面中复用。
<!-- 在主布局中引入 -->
<include
layout="@layout/layout_title_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
注意:如果给 include 设置了 id,会覆盖被引入布局根节点的 id,查找控件时需留意。
2.2 merge —— 减少层级
当被引入布局的根节点与父容器类型相同时(例如都是 LinearLayout),可用 替换根节点。inflate 时会直接将子 View 合并到父容器中,消除一层冗余。
<!-- layout_title_bar.xml 改写为 merge -->
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<ImageView android:id="@+id/iv_back" ... />
<TextView android:id="@+id/tv_title" ... />
</merge>
注意: 必须作为布局根节点,inflate 时必须指定父 ViewGroup 且 attachToRoot = true。
2.3 ViewStub —— 懒加载占位
ViewStub 本身不参与绘制、不占用布局空间,只有在需要时才加载目标布局。适合网络错误页、空数据页等「低频显示」场景,显著优化首屏渲染。
<ViewStub
android:id="@+id/view_stub_error"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout="@layout/layout_error_page" />
// 需要时加载
val errorView = viewStubError.inflate() // 加载后 ViewStub 自身被替换
// 或:viewStubError.visibility = View.VISIBLE
核心特点:ViewStub 只能 inflate 一次,加载完成后自身被替换为目标布局,再次调用 inflate 会抛异常。
三、触摸事件分发机制 —— 极简入门
Android 中所有点击、滑动、长按都基于触摸事件分发机制,理解它是解决滑动冲突的基础。
3.1 事件类型与核心方法
触摸事件由 MotionEvent 封装,核心序列:ACTION_DOWN → ACTION_MOVE → ACTION_UP / ACTION_CANCEL。
三大核心方法:
| 方法 | 作用 |
|---|---|
| dispatchTouchEvent() | 分发事件,所有事件先经过此方法 |
| onInterceptTouchEvent() | 拦截事件,只有 ViewGroup 才有 |
| onTouchEvent() | 消费事件,实际处理逻辑 |
3.2 分发流程总结
事件从 Activity → 顶层 ViewGroup → 逐层向下分发。一旦某个 View 消费了 ACTION_DOWN,后续 MOVE、UP 都直接交给它。如果所有子 View 都不消费,事件回传给父布局的 onTouchEvent。
3.3 滑动冲突解决思路
最常见场景:内外两层都可滑动(如 ScrollView 嵌套 RecyclerView)。
外部拦截法(父布局处理):重写父 ViewGroup 的 onInterceptTouchEvent,在符合条件时拦截。
内部拦截法(子 View 处理):子 View 通过 parent.requestDisallowInterceptTouchEvent(true) 禁止父布局拦截。
实际开发中,大部分滑动冲突可通过 NestedScrollView 或成熟第三方库解决,无需手写复杂逻辑。
四、自定义 View 初体验 —— 手写圆形进度条
当系统控件无法满足需求时,需要自定义 View。最基础的方式是继承 View,重写 onDraw。
4.1 自定义属性(res/values/attrs.xml)
<declare-styleable name="CircleProgressView">
<attr name="bgColor" format="color" />
<attr name="progressColor" format="color" />
<attr name="circleWidth" format="dimension" />
<attr name="textSize" format="dimension" />
<attr name="textColor" format="color" />
<attr name="max" format="integer" />
</declare-styleable>
4.2 完整实现
class CircleProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
// 所有画笔在构造方法中初始化,不在 onDraw 中创建
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
}
private var bgColor = Color.parseColor("#E0E0E0")
private var progressColor = Color.parseColor("#2196F3")
private var circleWidth = dp2px(8f)
private var textSize = sp2px(18f)
private var textColor = Color.parseColor("#333333")
private var max = 100
var progress: Int = 0
set(value) {
field = value.coerceIn(0, max)
invalidate()
}
init {
// 解析自定义属性
context.obtainStyledAttributes(attrs, R.styleable.CircleProgressView).apply {
bgColor = getColor(R.styleable.CircleProgressView_bgColor, bgColor)
progressColor = getColor(R.styleable.CircleProgressView_progressColor, progressColor)
circleWidth = getDimension(R.styleable.CircleProgressView_circleWidth, circleWidth)
textSize = getDimension(R.styleable.CircleProgressView_textSize, textSize)
textColor = getColor(R.styleable.CircleProgressView_textColor, textColor)
max = getInteger(R.styleable.CircleProgressView_max, max)
recycle()
}
bgPaint.color = bgColor
bgPaint.strokeWidth = circleWidth
progressPaint.color = progressColor
progressPaint.strokeWidth = circleWidth
textPaint.textSize = textSize
textPaint.color = textColor
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = resolveSize(dp2px(200f).toInt(), widthMeasureSpec)
val height = resolveSize(dp2px(200f).toInt(), heightMeasureSpec)
setMeasuredDimension(width, height)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 计算绘制区域,扣除 padding
val contentWidth = width - paddingLeft - paddingRight
val contentHeight = height - paddingTop - paddingBottom
val centerX = paddingLeft + contentWidth / 2f
val centerY = paddingTop + contentHeight / 2f
val radius = (min(contentWidth, contentHeight) / 2f - circleWidth / 2).coerceAtLeast(0f)
// 1. 背景圆环
canvas.drawCircle(centerX, centerY, radius, bgPaint)
// 2. 进度圆弧
val sweepAngle = progress.toFloat() / max * 360f
val rectF = RectF(centerX - radius, centerY - radius, centerX + radius, centerY + radius)
canvas.drawArc(rectF, -90f, sweepAngle, false, progressPaint)
// 3. 中心文字
val text = "$progress%"
val fm = textPaint.fontMetrics
val baseline = centerY - (fm.descent + fm.ascent) / 2
canvas.drawText(text, centerX, baseline, textPaint)
}
private fun dp2px(dp: Float) = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics
)
private fun sp2px(sp: Float) = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics
)
}
核心要点解读:
- 画笔在构造方法中初始化,onDraw 中绝不创建新对象,避免频繁 GC 掉帧。
- onMeasure 中处理 wrap_content 场景,指定默认尺寸。
- 绘制时扣除 padding,以 paddingLeft + contentWidth/2 作为圆心,确保 padding 生效。
- progress 的 setter 调用 invalidate(),每次更新自动重绘。
4.3 在布局中使用
<com.example.app.CircleProgressView
android:id="@+id/circle_progress"
android:layout_width="150dp"
android:layout_height="150dp"
android:padding="12dp"
app:bgColor="#E0E0E0"
app:progressColor="#2196F3"
app:circleWidth="8dp"
app:textSize="18sp"
app:textColor="#333333" />
circleProgress.progress = 65 // 设置到 65%
五、新手必踩坑点清单
坑点 ①:onDraw 中创建对象,频繁 GC 导致掉帧
错误示范:
override fun onDraw(canvas: Canvas) {
val paint = Paint() // ❌ onDraw 每秒调用 60 次,每次 new Paint 触发频繁 GC
canvas.drawCircle(...)
}
正确做法:所有画笔、路径、RectF 等对象在构造方法中统一初始化并复用,onDraw 只执行纯绘制逻辑。
坑点 ②:未处理 padding,自定义 View 显示偏移
问题表现:给自定义 View 设置 padding 后,内容位置偏移或被截断。
原因:系统控件默认处理了 padding,但自定义 View 不会自动适配,测量和绘制时都没有扣除 padding 值。
正确做法:
- onMeasure 计算可用空间时,考虑 paddingLeft/Right/Top/Bottom。
- onDraw 绘制时,以 padding 后的区域为基准,圆心从 paddingLeft + contentWidth/2 计算。
坑点 ③:忘记处理 wrap_content
默认情况下自定义 View 的 wrap_content 等同于 match_parent。务必在 onMeasure 中用 resolveSize 设置默认尺寸。
六、综合小案例:闹钟设置界面
整合所有知识点,做一个闹钟设置界面:
-
TimePicker 设置时间
-
CheckBox 开启/关闭重复
-
SeekBar 调节音量
-
自定义 CircleProgressView 展示当前时间进度(模拟时钟指针)
6.1 布局文件
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal">
<!-- 自定义时钟(模拟当前时间进度) -->
<com.example.app.CircleProgressView
android:id="@+id/clock_view"
android:layout_width="180dp"
android:layout_height="180dp"
android:padding="16dp"
app:bgColor="#E0E0E0"
app:progressColor="#FF9800" />
<!-- 时间选择器 -->
<TimePicker
android:id="@+id/time_picker"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp" />
<!-- 重复开关 -->
<CheckBox
android:id="@+id/cb_repeat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="每天重复" />
<!-- 音量调节 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="16dp">
<TextView android:text="闹钟音量:" android:textSize="16sp" />
<SeekBar
android:id="@+id/seekbar_volume"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="100"
android:progress="70"
android:layout_marginStart="12dp" />
<TextView
android:id="@+id/tv_volume"
android:layout_width="40dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text="70%" />
</LinearLayout>
<!-- 保存按钮 -->
<Button
android:id="@+id/btn_save"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="保存闹钟" />
</LinearLayout>
</ScrollView>
6.2 Activity 代码
class AlarmActivity : AppCompatActivity() {
private lateinit var clockView: CircleProgressView
private lateinit var timePicker: TimePicker
private lateinit var cbRepeat: CheckBox
private lateinit var seekbarVolume: SeekBar
private lateinit var tvVolume: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_alarm)
clockView = findViewById(R.id.clock_view)
timePicker = findViewById(R.id.time_picker)
cbRepeat = findViewById(R.id.cb_repeat)
seekbarVolume = findViewById(R.id.seekbar_volume)
tvVolume = findViewById(R.id.tv_volume)
timePicker.setIs24HourView(true)
// 模拟当前时间指针:比如现在是 10 点,10/24 ≈ 42%
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
clockView.progress = (hour.toFloat() / 24f * 100f).toInt()
seekbarVolume.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
tvVolume.text = "$progress%"
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
findViewById<Button>(R.id.btn_save).setOnClickListener {
val hour = timePicker.hour
val minute = timePicker.minute
val repeat = if (cbRepeat.isChecked) "每天" else "单次"
val volume = seekbarVolume.progress
Toast.makeText(this, "闹钟已保存:$hour:$minute $repeat 音量 $volume%", Toast.LENGTH_SHORT).show()
}
}
}
6.3 运行效果
- 自定义圆形时钟展示当前时间的指针进度。
- TimePicker 选择闹钟时间。
- CheckBox 控制是否每天重复。
- SeekBar 拖动调节音量,右侧文字实时同步。
- 点击「保存闹钟」弹出设置摘要。
七、总结与下篇预告
今天我们完成了一场 View 组件的大阅兵,核心要点:
- ✅ 常用控件:EditText + TextWatcher 实时监听、CheckBox + RadioGroup 选择逻辑、SeekBar + ProgressBar 进度展示。
- ✅ 布局优化: 复用、 减层级、 懒加载,三者场景与注意事项。
- ✅ 触摸事件:分发机制的核心链路(分发 → 拦截 → 消费),以及滑动冲突的两种解决思路。
- ✅ 自定义 View:继承 View → 解析自定义属性 → onMeasure 处理宽高 → onDraw 用 Canvas + Paint 绘制,完整流程。
- ✅ 避坑指南:不在 onDraw 中 new 对象、正确处理 padding 和 wrap_content。
- ✅ 综合案例:闹钟设置界面,整合 TimePicker、CheckBox、SeekBar 和自定义 CircleProgressView。
自定义 View 是 Android UI 开发的进阶核心,掌握了 onMeasure + onDraw 的基础流程后,后续可以进一步学习 onLayout、自定义 ViewGroup、属性动画等内容。下一篇,我们将进入项目整合与 APK 打包发布,把前面学到的所有知识串联起来,打出一个真正可以安装的 App!
✨ 如果本文对你有帮助,欢迎点赞、收藏,让更多零基础的小伙伴少走弯路!