ToggleButton 详解:手搓自定义 View
每个 Android 开发者,在成长路上都应该手搓一次 ToggleButton——那个看起来简单的"iOS 风格开关",其实浓缩了自定义 View 的全部核心:测量、绘制、触摸事件、属性动画、自定义属性。zcweng 的 ToggleButton(及同作者的 SwitchButton)是 GitHub 上最经典的开源实现之一,既是拿来即用的 UI 组件,更是学习"如何从零手搓自定义 View"的教科书级源码。本文先介绍这个库,再把它拆开讲清手搓原理——读完后,你应该能自己写一个。
GitHub 仓库:zcweng/ToggleButton;同作者进阶版:zcweng/SwitchButton(更轻量、更易定制)。
1、ToggleButton 是什么
ToggleButton 是一个 Android 自定义 View,视觉效果是经典的"iOS 风格开关"——一个圆角矩形轨道,里面有一个圆按钮,拨动时圆按钮从左滑到右,轨道颜色随之变化。它替代了 Android 原生 Switch/ToggleButton 的" Material 风格"外观,让 Android App 也能拥有 iOS 那种简洁的开关体验。
// 核心定位
ToggleButton = 自定义 View 实现的 iOS 风格开关
// 与原生方案对比
// 原生 Switch/ToggleButton:Material 风格,外观固定,定制有限
// zcweng ToggleButton:完全自绘,轨道/圆钮/颜色/动画全可配
// 两个版本
// ToggleButton:经典版,功能完整(3.5k+ Star)
// SwitchButton:同作者进阶版,更轻量、更易定制(MIT 许可)
2、为什么"每个 Android 开发都该手搓一个"
你可能会问:"不是有现成库吗,直接依赖不就行了?"当然可以,但手搓一次 ToggleButton 的学习价值远超"省一个依赖"。因为一个 ToggleButton 完整覆盖了自定义 View 的五大核心:
// 手搓 ToggleButton,你会实战以下全部知识点:
1. onMeasure:测量控件大小(宽高),处理 wrap_content
2. onSizeChanged:拿到最终宽高,计算圆钮半径、轨道边界
3. onDraw:用 Canvas + Paint 画轨道(圆角矩形)、画圆钮、画渐变
4. onTouchEvent:处理 ACTION_DOWN/MOVE/UP,实现拖拽与点击切换
5. 属性动画:ValueAnimator 让圆钮平滑滑动、轨道颜色渐变过渡
// 附加:
6. 自定义属性(attrs.xml):让 XML 里可以配颜色/大小/初始状态
7. 回调接口:OnToggleChanged,状态变化时通知外部
8. 状态保存:onSaveInstanceState,旋转屏幕不丢状态
这八项,基本就是"Android 自定义 View 入门到进阶"的全部大纲。一个 ToggleButton,串起一整门课。读完 zcweng 的源码再自己写一遍,你的自定义 View 能力会跨一个大台阶。
3、手搓第一步:测量与尺寸计算
自定义 View 的第一个难关是 onMeasure——告诉系统"我这个开关有多大"。ToggleButton 需要处理"用户没写死宽高(wrap_content)"的情况,给它一个合理的默认尺寸:
class ToggleButton @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyle: Int = 0
) : View(context, attrs, defStyle) {
private val defaultWidth = dp2px(56) // 默认宽 56dp(参考 iOS 尺寸)
private val defaultHeight = dp2px(28) // 默认高 28dp
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = measureSize(widthMeasureSpec, defaultWidth)
val height = measureSize(heightMeasureSpec, defaultHeight)
setMeasuredDimension(width, height)
}
private fun measureSize(spec: Int, default: Int): Int {
val mode = MeasureSpec.getMode(spec)
val size = MeasureSpec.getSize(spec)
return when (mode) {
MeasureSpec.EXACTLY -> size // match_parent 或写死值
MeasureSpec.AT_MOST -> min(default, size) // wrap_content,取默认与上限的较小值
else -> default // UNSPECIFIED,用默认
}
}
} measureSize 是自定义 View 最经典的"三模式处理"模板——EXACTLY 用用户值、AT_MOST(wrap_content)用默认值且不超上限、UNSPECIFIED 用默认值。掌握这个模板,90% 的自定义 View 测量都搞定。
4、手搓第二步:绘制(onDraw)
ToggleButton 的视觉就两样东西:圆角矩形轨道和圆形按钮(thumb)。在 onDraw 里用 Canvas + Paint 画出来:
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val thumbPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private var thumbRadius = 0f // 圆钮半径
private var thumbCenterX = 0f // 圆钮当前中心 X(随状态变化)
private val cornerRadius = 0f // 轨道圆角
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
// 控件最终尺寸确定后,计算各参数
thumbRadius = h / 2f - padding // 圆钮半径 = 半高减边距
val left = thumbRadius + padding // 圆钮左边极限
val right = w - thumbRadius - padding // 圆钮右边极限
thumbCenterX = if (isOn) right else left // 根据状态设初始位置
cornerRadius = h / 2f // 轨道圆角 = 半高(胶囊形)
}
override fun onDraw(canvas: Canvas) {
// 1. 画轨道(圆角矩形,颜色随开关状态变化)
trackPaint.color = if (isOn) onColor else offColor
canvas.drawRoundRect(
0f, 0f, width.toFloat(), height.toFloat(),
cornerRadius, cornerRadius, trackPaint
)
// 2. 画圆钮
canvas.drawCircle(thumbCenterX, height / 2f, thumbRadius, thumbPaint)
} onSizeChanged 是"布局完成后算参数"的最佳时机——此时 width/height 已确定,可以算半径、边界。onDraw 只负责"按算好的参数画",不做复杂计算(性能敏感)。这套"onSizeChanged 算参数 → onDraw 纯画"的分工,是自定义 View 的标准姿势。
5、手搓第三步:触摸与切换
开关的核心交互是"点击切换 + 拖拽滑动"。在 onTouchEvent 里处理两种手势:
private var downX = 0f
private var isDragging = false
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
downX = event.x
isDragging = false
return true // 消费 DOWN,才能收到后续 MOVE/UP
}
MotionEvent.ACTION_MOVE -> {
val dx = event.x - downX
if (abs(dx) > touchSlop) {
isDragging = true
// 拖拽:圆钮跟着手指走(限制在 left~right 之间)
thumbCenterX = clamp(thumbCenterX + dx, left, right)
downX = event.x
invalidate() // 触发重绘
}
}
MotionEvent.ACTION_UP -> {
if (!isDragging) {
// 纯点击:直接切换状态
toggle()
} else {
// 拖拽结束:根据圆钮位置判断最终状态
val isOnNow = thumbCenterX > (left + right) / 2
setOn(isOnNow, animate = true)
}
}
}
return super.onTouchEvent(event)
} 两个关键点:第一,ACTION_DOWN 必须返回 true(消费事件),否则后续 MOVE/UP 收不到;第二,区分"点击"和"拖拽"——用手势移动距离是否超过 touchSlop 判断,点击直接 toggle,拖拽则跟手滑动后在 UP 时定态。这套逻辑是所有"可拖拽自定义控件"的通用模板。
6、手搓第四步:动画与状态回调
开关切换时,圆钮应该平滑滑动而非瞬间跳过去——用 ValueAnimator 实现:
fun setOn(on: Boolean, animate: Boolean) {
if (isOn == on) return
isOn = on
val targetX = if (on) right else left
if (animate) {
// 属性动画:圆钮从当前位置滑到目标
ValueAnimator.ofFloat(thumbCenterX, targetX).apply {
duration = 250
interpolator = AccelerateDecelerateInterpolator()
addUpdateListener { anim ->
thumbCenterX = anim.animatedValue as Float
invalidate() // 每帧重绘
}
start()
}
// 轨道颜色也可以同时做渐变(ArgbEvaluator)
} else {
thumbCenterX = targetX
invalidate()
}
// 回调外部
onToggleChanged?.onToggle(this, on)
}
// 回调接口
interface OnToggleChanged {
fun onToggle(view: ToggleButton, isOn: Boolean)
}
var onToggleChanged: OnToggleChanged? = null ValueAnimator + invalidate 是自定义 View 做动画的"万能公式":动画驱动数值变化 → 数值变化触发 invalidate → onDraw 用新数值重画 → 连续帧拼出动画。掌握这套模式,任何自定义控件的动画你都能写——进度条、仪表盘、波形图,原理一样。
7、自定义属性:让 XML 可配
// res/values/attrs.xml
<declare-styleable name="ToggleButton">
<attr name="tbOnColor" format="color" /> // 开启时轨道色
<attr name="tbOffColor" format="color" /> // 关闭时轨道色
<attr name="tbThumbColor" format="color" /> // 圆钮颜色
<attr name="tbIsOn" format="boolean" /> // 初始状态
<attr name="tbAnimDuration" format="integer" /> // 动画时长
</declare-styleable>
// 在 View 构造里读取
val ta = context.obtainStyledAttributes(attrs, R.styleable.ToggleButton)
onColor = ta.getColor(R.styleable.ToggleButton_tbOnColor, defaultOnColor)
offColor = ta.getColor(R.styleable.ToggleButton_tbOffColor, defaultOffColor)
isOn = ta.getBoolean(R.styleable.ToggleButton_tbIsOn, false)
ta.recycle() // 别忘了 recycle
// XML 里使用
// <com.example.ToggleButton
// app:tbOnColor="#4CAF50"
// app:tbOffColor="#CCCCCC"
// app:tbIsOn="true" ... />
declare-styleable + obtainStyledAttributes + recycle 是自定义属性的"三件套"。让用户在 XML 里配颜色/状态/动画时长,是一个"自定义 View"区别于"写死的 View"的关键——没有这一步,你的控件只能写死、别人无法复用。
8、从 zcweng 库到手搓:学习路径与避坑
- 第一步:clone zcweng/ToggleButton 源码,通读核心类(约 500 行),理解"他怎么做的"
- 第二步:关掉源码,自己从空白 View 开始写——onMeasure → onDraw → onTouchEvent → 动画 → 自定义属性
- 第三步:对照 zcweng 的实现"纠错",看他哪里比你做得好(通常是动画插值器、边界处理、状态保存)
- 常见坑1:忘了在 ACTION_DOWN 返回 true,导致 MOVE/UP 收不到
- 常见坑2:onDraw 里 new 对象(Paint/Path),被 lint 警告"不要在 draw 中分配对象"
- 常见坑3:动画结束没"定格"到最终状态,圆钮停在半路
- 常见坑4:旋转屏幕状态丢失(没做 onSaveInstanceState)
- 终极目标:不看任何参考,10 分钟内从零写出一个功能完整的 ToggleButton
9、总结
zcweng 的 ToggleButton 是 GitHub 上最经典的自定义开关 View,但它的价值远不止"拿来用"——它是一本浓缩的"自定义 View 教材"。一个 ToggleButton 完整覆盖了 onMeasure 测量、onDraw 绘制(Canvas + Paint + drawRoundRect + drawCircle)、onTouchEvent 触摸(点击+拖拽+touchSlop)、ValueAnimator 动画、自定义属性(attrs.xml)、回调接口、状态保存——自定义 View 的全部核心知识点,一个控件串完。
关键要点:
- ToggleButton = iOS 风格开关,完全自绘,轨道/圆钮/颜色/动画全可配
- 手搓一次 ToggleButton = 实战自定义 View 的八大核心知识点
- 四步曲:onMeasure(测量)→ onDraw(绘制)→ onTouchEvent(触摸)→ ValueAnimator(动画)
- 自定义属性三件套:declare-styleable + obtainStyledAttributes + recycle
- 学习路径:读 zcweng 源码 → 自己从零写 → 对照纠错 → 裸写 10 分钟搞定
对于每个 Android 开发者而言,ToggleButton 是"自定义 View 的里程碑"——它不难,但完整;它不大,但深刻。clone 下来 zcweng 的源码读一遍、再关掉自己写一遍,你的自定义 View 能力会肉眼可见地跨一个台阶。这不是"学一个库",而是"学一种能力"——之后遇到任何自定义控件需求,你都能拆解成"测量+绘制+触摸+动画"四步搞定。这就是"每个 Android 开发都该手搓一个 ToggleButton"的真正含义。
参考资料:
zcweng/ToggleButton(GitHub 经典版)
zcweng/SwitchButton(同作者进阶版,更轻量)