自定义ToolbarView实战指南(Kotlin版)
一、为什么我们需要造轮子?
看到标题你可能会问:系统自带Toolbar不香吗?确实香,但遇到这些场景就抓瞎了:
- 设计稿要求标题栏带渐变背景+动态波浪线
- 产品经理非要搞个不对称的返回按钮布局
- UI设计师坚持标题和副标题要45度角重叠
这时候再不自己动手撸View,就只能等着加班掉头发了!
二、从零打造ToolbarView
2.1 骨架搭建
class ToolbarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
// 三大核心组件
private lateinit var backButton: ImageView
private lateinit var titleView: TextView
private lateinit var actionMenu: LinearLayout
// 初始化三连
init {
initAttrs(attrs)
initViews()
setupClickListeners()
}
}
关键点解析:
- 继承ViewGroup而不是直接继承Toolbar(保持最大自由度)
- 采用组合模式而不是继承(方便后续扩展)
- 初始化拆分为属性解析、视图创建、事件绑定三步
2.2 测量布局实战
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val maxWidth = MeasureSpec.getSize(widthMeasureSpec)
var totalHeight = 0
// 测量返回按钮
backButton.measure(
MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
)
totalHeight = max(totalHeight, backButton.measuredHeight)
// 测量标题(最多占用剩余宽度的70%)
val titleMaxWidth = (maxWidth - backButton.measuredWidth) * 0.7f
titleView.measure(
MeasureSpec.makeMeasureSpec(titleMaxWidth.toInt(), MeasureSpec.AT_MOST),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
)
totalHeight = max(totalHeight, titleView.measuredHeight)
// 设置最终尺寸
setMeasuredDimension(maxWidth, resolveSize(totalHeight + paddingTop + paddingBottom, heightMeasureSpec))
}
避坑指南:
- 处理wrap_content时需要特别注意AT_MOST模式
- 带IconFont的TextView需要单独处理CompoundDrawable测量
- 多语言文本可能导致测量抖动,需要添加layout稳定机制
2.3 自定义属性大全
<!-- res/values/attrs.xml -->
<declare-styleable name="ToolbarView">
<!-- 背景相关 -->
<attr name="toolbarBackground" format="color|reference" />
<attr name="cornerRadius" format="dimension" />
<!-- 标题样式 -->
<attr name="titleText" format="string" />
<attr name="titleTextColor" format="color" />
<attr name="titleTextSize" format="dimension" />
<!-- 返回按钮特殊配置 -->
<attr name="backIcon" format="reference" />
<attr name="backIconTint" format="color" />
</declare-styleable>
属性解析黑科技:
private fun initAttrs(attrs: AttributeSet?) {
context.obtainStyledAttributes(attrs, R.styleable.ToolbarView).apply {
// 解析渐变背景
getDrawable(R.styleable.ToolbarView_toolbarBackground)?.let {
background = if (it is GradientDrawable) {
it.apply { cornerRadius = getDimension(...) }
} else it
}
// 动态创建标题View
titleView.text = getString(R.styleable.ToolbarView_titleText)
titleView.setTextColor(getColor(R.styleable.ToolbarView_titleTextColor, Color.BLACK))
// 返回按钮图标处理
backButton.setImageDrawable(getDrawable(R.styleable.ToolbarView_backIcon))
DrawableCompat.setTint(backButton.drawable, getColor(...))
recycle()
}
}
三、高级技巧加持
3.1 沉浸式状态栏适配
fun fitsSystemWindow() {
val statusBarHeight = getStatusBarHeight()
setPadding(paddingLeft, paddingTop + statusBarHeight, paddingRight, paddingBottom)
layoutParams = layoutParams.apply { height += statusBarHeight }
}
private fun getStatusBarHeight(): Int {
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
}
3.2 动态主题切换
fun applyDarkTheme(isDark: Boolean) {
val textColor = if (isDark) Color.WHITE else Color.BLACK
val iconTint = if (isDark) Color.WHITE else Color.DKGRAY
titleView.setTextColor(textColor)
backButton.drawable.setTint(iconTint)
actionMenu.children.forEach { (it as? ImageView)?.drawable?.setTint(iconTint) }
// 带动画效果更丝滑
animate().setDuration(300).alpha(0.8f).withEndAction { animate().alpha(1f) }
}
四、调试踩坑记录
4.1 触摸事件冲突
症状:滑动返回手势和按钮点击冲突
处方:重写onInterceptTouchEvent
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
return when {
// 在左右边缘30dp内时交给系统处理滑动返回
ev.x < 30.dp || ev.x > width - 30.dp -> false
// 其他区域自己处理点击
else -> super.onInterceptTouchEvent(ev)
}
}
4.2 内存泄漏检测
在onDetachedFromWindow中释放资源:
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// 清除动画
clearAnimation()
// 解绑回调
backButton.setOnClickListener(null)
// 释放大图资源
backButton.setImageDrawable(null)
}
经验之谈:自定义View就像搭积木,先拆解设计稿,再组合基础组件,最后打磨细节。记得多用Canvas.saveLayer()来调试绘制范围!