23、DataBinding与自定义ViewGroup:自定义布局绑定、子View绑定、布局参数绑定、测量与布局。
说实话,很多Android开发者做到自定义ViewGroup这一步就开始头疼了。尤其是当DataBinding掺和进来,感觉就像两套体系在打架。但你别急,我做了这么多年自定义布局,可以负责任地告诉你——DataBinding和自定义ViewGroup其实是天生一对。
为什么这么说?因为自定义ViewGroup最烦的就是手动管理子View的数据绑定和布局参数。DataBinding正好能帮你把这些脏活累活自动化。今天我就带你把这套组合拳打明白。
核心要点:DataBinding在自定义ViewGroup中的价值,在于把「数据驱动布局」的理念贯彻到底。你不需要在代码里写一堆findViewById和setLayoutParams,而是通过绑定表达式直接控制子View的尺寸、位置和行为。
23.1 自定义布局绑定的基本套路
我们先从最基础的开始。假设你要做一个流式布局(FlowLayout),每个子View的宽度由数据决定。传统做法是遍历子View,一个个设置参数。但用DataBinding,你可以这样做:
// FlowLayoutWithBinding.kt
class FlowLayoutWithBinding @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
private val binding: LayoutFlowBinding? = null
// 注意:这里不能直接用DataBindingUtil.inflate
// 因为ViewGroup的布局是在父容器中决定的
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// 布局逻辑
}
}
嗯,这里要注意一个坑。我刚开始用DataBinding做自定义ViewGroup时,总想着在构造函数里就把binding初始化好。结果发现不行——因为自定义ViewGroup的布局文件通常是在父容器的XML里引用的,而不是通过inflate生成的。
正确的做法是:在addView的时候,把DataBinding绑定上去。
fun addItem(item: ItemData) {
val itemBinding = ItemBinding.inflate(
LayoutInflater.from(context),
this,
false
)
itemBinding.item = item
itemBinding.executePendingBindings()
// 关键:把binding的root添加到ViewGroup
addView(itemBinding.root)
}
你看,这样每个子View的布局和数据就通过DataBinding绑死了。你只需要关心数据模型,不用管View怎么创建。
23.2 子View绑定:让每个孩子都有自己的数据
在实际项目中,我遇到过这样一个场景:一个卡片式布局,每个卡片显示不同的用户信息,点击后还要跳转。如果不用DataBinding,你得在Adapter里写一堆ViewHolder逻辑。但用自定义ViewGroup+DataBinding,代码清爽多了。
class CardContainer @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
private val childrenBindings = mutableListOf<CardBinding>()
fun setCards(cards: List<CardData>) {
removeAllViews()
childrenBindings.clear()
cards.forEach { cardData ->
val binding = CardBinding.inflate(
LayoutInflater.from(context),
this,
false
)
binding.card = cardData
binding.handler = CardClickHandler()
binding.executePendingBindings()
childrenBindings.add(binding)
addView(binding.root)
}
requestLayout()
}
}
这里有个小技巧:把binding列表保存下来。为什么?因为有时候你需要动态更新某个子View的数据,直接通过binding列表找到对应的binding,改一下数据就行了,不用重新创建整个View。
个人经验:我习惯在自定义ViewGroup里维护一个binding列表,而不是View列表。因为binding里包含了数据引用,方便做局部刷新。但要注意内存泄漏——在removeView时,记得把对应的binding也从列表里移除。
23.3 布局参数绑定:让DataBinding决定子View的尺寸
这才是重头戏。你想想看,自定义ViewGroup最核心的工作是什么?就是测量和布局。而测量和布局的依据,就是每个子View的LayoutParams。
传统做法是在XML里写死layout_width和layout_height,或者在代码里new LayoutParams。但有了DataBinding,我们可以让布局参数也变成数据驱动的。
// 自定义LayoutParams
class FlexLayoutParams : MarginLayoutParams {
@BindingAdapter("layout_flexWeight")
var flexWeight: Float = 0f
@BindingAdapter("layout_flexGrow")
var flexGrow: Boolean = false
constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) {
// 从XML解析自定义属性
}
constructor(width: Int, height: Int) : super(width, height)
}
// 在ViewGroup中重写generateLayoutParams
override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
return FlexLayoutParams(context, attrs)
}
override fun generateLayoutParams(p: LayoutParams): LayoutParams {
return FlexLayoutParams(p.width, p.height)
}
然后在XML里,你就可以这样用:
<com.example.FlexLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_flexWeight="1"
app:layout_flexGrow="true"
android:text="@{user.name}" />
</com.example.FlexLayout>
你看,layout_flexWeight这个属性直接通过DataBinding的BindingAdapter绑定到了LayoutParams上。在onMeasure里,我就可以根据这些参数计算每个子View的宽度。
避坑指南:我曾经犯过一个错误——在BindingAdapter里直接修改LayoutParams的字段,但没有调用requestLayout。结果界面死活不刷新。记住:修改LayoutParams后,一定要调用requestLayout(),否则测量和布局不会重新执行。
23.4 测量与布局:把数据翻译成像素
好了,现在数据有了,LayoutParams也绑定了,接下来就是真刀真枪地干测量和布局了。这部分我建议你画个图理清思路。
看明白这个流程了吗?说白了就是:数据决定参数,参数决定尺寸,尺寸决定位置。每一步都环环相扣。
下面我给出一个完整的onMeasure和onLayout示例,结合DataBinding的LayoutParams:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
var totalHeight = paddingTop + paddingBottom
var maxWidth = 0
// 先测量所有子View
for (i in 0 until childCount) {
val child = getChildAt(i)
val lp = child.layoutParams as FlexLayoutParams
// 关键:从LayoutParams读取绑定数据
val childWidthSpec: Int
val childHeightSpec: Int
if (lp.flexWeight > 0f && widthMode == MeasureSpec.EXACTLY) {
// 按权重分配宽度
val weightedWidth = (widthSize * lp.flexWeight).toInt()
childWidthSpec = MeasureSpec.makeMeasureSpec(
weightedWidth - lp.leftMargin - lp.rightMargin,
MeasureSpec.EXACTLY
)
} else {
childWidthSpec = getChildMeasureSpec(
widthMeasureSpec,
paddingLeft + paddingRight + lp.leftMargin + lp.rightMargin,
lp.width
)
}
childHeightSpec = getChildMeasureSpec(
heightMeasureSpec,
paddingTop + paddingBottom + lp.topMargin + lp.bottomMargin,
lp.height
)
child.measure(childWidthSpec, childHeightSpec)
maxWidth = max(maxWidth, child.measuredWidth)
totalHeight += child.measuredHeight
}
setMeasuredDimension(
resolveSize(maxWidth + paddingLeft + paddingRight, widthMeasureSpec),
resolveSize(totalHeight, heightMeasureSpec)
)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var currentTop = paddingTop
for (i in 0 until childCount) {
val child = getChildAt(i)
val lp = child.layoutParams as FlexLayoutParams
val left = paddingLeft + lp.leftMargin
val top = currentTop + lp.topMargin
val right = left + child.measuredWidth
val bottom = top + child.measuredHeight
child.layout(left, top, right, bottom)
currentTop = bottom + lp.bottomMargin
}
}
我的习惯:在onMeasure里,我喜欢加一个日志开关。调试时打开,看看每个子View的LayoutParams里的flexWeight值对不对。因为有时候BindingAdapter没生效,权重全是0,布局就全乱了。加一行Log.d就能省去半小时的排查时间。
23.5 实战:一个完整的DataBinding自定义ViewGroup
最后,我把上面所有的知识点串起来,给你一个可以直接用的模板。这是一个支持权重分配的线性布局,所有子View的宽度由数据驱动。
// WeightLinearLayout.kt
class WeightLinearLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
private val bindingList = mutableListOf<ViewDataBinding>()
fun bindItems(items: List<WeightItem>) {
removeAllViews()
bindingList.clear()
items.forEach { item ->
val binding = ItemWeightBinding.inflate(
LayoutInflater.from(context),
this,
false
)
binding.item = item
binding.executePendingBindings()
// 设置LayoutParams
val lp = WeightLayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT
)
lp.weight = item.weight
binding.root.layoutParams = lp
bindingList.add(binding)
addView(binding.root)
}
requestLayout()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// ... 测量逻辑,参考上面的示例
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// ... 布局逻辑,参考上面的示例
}
}
// 数据模型
data class WeightItem(
val weight: Float,
val text: String,
val backgroundColor: Int
)
// 自定义LayoutParams
class WeightLayoutParams : MarginLayoutParams {
var weight: Float = 0f
constructor(width: Int, height: Int) : super(width, height)
constructor(c: Context, attrs: AttributeSet?) : super(c, attrs)
}
你看,整个流程下来,代码量其实不多。但每个环节都踩过坑——我最早做这个的时候,忘了在bindItems里调用executePendingBindings(),结果数据死活不显示。后来养成习惯:每次设置完数据,立刻调用executePendingBindings()。
DataBinding和自定义ViewGroup的结合,本质上就是把「布局逻辑」和「数据逻辑」解耦。你只需要关注数据怎么来、参数怎么算,剩下的测量和布局交给框架。这才是现代Android开发的正确姿势。
公众号:蓝海资料掘金营,微信deep3321