第 11 篇|常用 View 组件大阅兵与自定义 View 初体验

9 阅读6分钟

常用 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!


✨ 如果本文对你有帮助,欢迎点赞、收藏,让更多零基础的小伙伴少走弯路!