自定义 ViewGroup:实现一个流式标签布局

流程

自定义 ViewGroup 的流程为:

  • 重写 onMeasure 方法

    遍历并测量每个子 View,将每个子 View 的实际位置和尺寸暂存下来。(有些子 View 可能需要重新测量)

    根据测量结果,计算自己的尺寸,然后调用 setMeasuredDimension(width, height) 保存。

  • 重写 onLayout 方法

    遍历子 View,调用它们的 layout() 方法并传入每个子 View 的位置和尺寸。

实现简易的 TagLayout

其大致效果是这样的:

实现 TagView

我们先来实现标签部分。

准备工作:创建 TagView,继承自 AppCompatTextView

首先添加内边距。

kotlin 复制代码
class TagView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
) : AppCompatTextView(context, attrs, defStyleAttr) {

    private val paddingHorizontal = 10.dp
    private val paddingVertical = 5.dp

    init {
        setPadding(
            paddingHorizontal.toInt(),
            paddingVertical.toInt(),
            paddingHorizontal.toInt(),
            paddingVertical.toInt()
        )
    }
}

然后绘制圆角背景即可。

kotlin 复制代码
init {
    // ...
    setTextColor(Color.WHITE)
}


private val colors = intArrayOf(
    Color.parseColor("#FFEB3B"),
    Color.parseColor("#FF5722"),
    Color.parseColor("#9C27B0"),
    Color.parseColor("#3F51B5"),
    Color.parseColor("#009688"),
    Color.parseColor("#795548"),
    Color.parseColor("#607D8B"),
    Color.parseColor("#E91E63"),
    Color.parseColor("#CDDC39"),
    Color.parseColor("#4CAF50"),
    Color.parseColor("#FF9800"),
)

private val myPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { // 使用 ANTI_ALIAS_FLAG 抗锯齿
    color = colors.random()
    style = Paint.Style.FILL
}

override fun onDraw(canvas: Canvas) {
    // 先绘制背景,再调用 super.onDraw 绘制文本,防止文本被覆盖
    canvas.drawRoundRect(0f, 0f, width.toFloat(), height.toFloat(), 16.dp, 16.dp, myPaint)
    super.onDraw(canvas)
}

在布局中使用:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <com.example.customview.TagView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Kotlin" />


    <com.example.customview.TagView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Java" />

    <com.example.customview.TagView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Tag" />

    <com.example.customview.TagView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Android" />

</LinearLayout>

运行效果:

实现 TagLayout

然后实现标签布局(目前先实现单行标签)。

创建 TagLayout,继承自 ViewGroup,实现 onLayout 抽象方法:

kotlin 复制代码
class TagLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        
    }
}

我们先按照步骤,搭出大致框架:

kotlin 复制代码
class TagLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0,
) : ViewGroup(context, attrs, defStyleAttr) {

    // 每个子View的边界
    private val childrenBounds = mutableListOf<Rect>()

    @SuppressLint("DrawAllocation")
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 遍历并测量子View
        for ((index, child) in children.withIndex()) {
            val childWidthMeasureSpec = 0
            val childHeightMeasureSpec = 0
            child.measure(childWidthMeasureSpec, childHeightMeasureSpec)

            // 获取或创建用于存储子View边界的Rect对象
            val childBounds =
                childrenBounds.getOrNull(index) ?: Rect().also { childrenBounds.add(it) }
        }

        // 计算自身尺寸
        val selfWidth = 0
        val selfHeight = 0
        setMeasuredDimension(selfWidth, selfHeight)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // 遍历并布局每个子View
        for ((index, child) in children.withIndex()) {
            val childBounds = childrenBounds[index]
            child.layout(childBounds.left, childBounds.top, childBounds.right, childBounds.bottom)
        }
    }
}

然后填充每个值,首先是我们对子 View 的具体尺寸要求 childWidthMeasureSpecchildHeightMeasureSpec,我们需要结合开发者填入的尺寸要求,以及当前的可用空间得出。

其中第一个可以通过子 View 的 getLayoutParams() 方法获取,第二个需要根据父 View 传入的尺寸要求,以及当前已使用空间得出。

如果我们手动来计算的话,代码会是下面这样的:

kotlin 复制代码
@SuppressLint("DrawAllocation")
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

    // 规格参数
    val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)
    val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec)
    val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec)
    val heightSpecSize = MeasureSpec.getSize(heightMeasureSpec)

    // 布局中已使用的空间
    var widthSizeUsed = 0 // 当前行已使用的宽度
    var heightSizeUsed = 0 // 已布局完成的所有行所占的总高度

    // 遍历并测量子View
    for ((index, child) in children.withIndex()) {
        // 获取子View的布局参数
        val params = child.layoutParams
        var childWidthMeasureSpec: Int
        var childHeightMeasureSpec: Int

        // 计算宽度 MeasureSpec
        when (params.width) {
            // 子 View 希望和父容器的剩余空间一样大
            LayoutParams.MATCH_PARENT -> {
                // 如果父容器有精确尺寸或最大尺寸,子 View 就必须精确地填充该剩余空间。
                childWidthMeasureSpec =
                    if (widthSpecMode == MeasureSpec.EXACTLY || widthSpecMode == MeasureSpec.AT_MOST) {
                        MeasureSpec.makeMeasureSpec(
                            widthSpecSize - widthSizeUsed, MeasureSpec.EXACTLY
                        )
                    }
                    // 如果父容器没有尺寸限制 (UNSPECIFIED),那么 MATCH_PARENT 就没有意义。
                    else { // UNSPECIFIED
                        MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
                    }
            }
            // 子 View 希望尺寸刚好能包裹其内容
            LayoutParams.WRAP_CONTENT -> {
                // 如果父容器有精确尺寸或最大尺寸,那么子 View 的尺寸不能超过该限制。
                childWidthMeasureSpec =
                    if (widthSpecMode == MeasureSpec.EXACTLY || widthSpecMode == MeasureSpec.AT_MOST) {
                        MeasureSpec.makeMeasureSpec(
                            widthSpecSize - widthSizeUsed, MeasureSpec.AT_MOST
                        )
                    }
                    // 如果父容器没有尺寸限制,子 View 就可以是任意它需要的大小。
                    else {
                        MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
                    }
            }
            // 子 View 是一个固定尺寸
            else -> {
                // 让子 View 获得它明确要求的精确尺寸。
                childWidthMeasureSpec =
                    MeasureSpec.makeMeasureSpec(params.width, MeasureSpec.EXACTLY)
            }
        }

        // 计算高度 MeasureSpec
        when (params.height) {
            LayoutParams.MATCH_PARENT -> {
                childHeightMeasureSpec =
                    if (heightSpecMode == MeasureSpec.EXACTLY || heightSpecMode == MeasureSpec.AT_MOST) {
                        MeasureSpec.makeMeasureSpec(
                            heightSpecSize - heightSizeUsed, MeasureSpec.EXACTLY
                        )
                    } else {
                        MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
                    }
            }

            LayoutParams.WRAP_CONTENT -> {
                childHeightMeasureSpec =
                    if (heightSpecMode == MeasureSpec.EXACTLY || heightSpecMode == MeasureSpec.AT_MOST) {
                        MeasureSpec.makeMeasureSpec(
                            heightSpecSize - heightSizeUsed, MeasureSpec.AT_MOST
                        )
                    } else {
                        MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
                    }
            }

            else -> {
                childHeightMeasureSpec =
                    MeasureSpec.makeMeasureSpec(params.height, MeasureSpec.EXACTLY)
            }
        }
        // 测量子 View
        child.measure(childWidthMeasureSpec, childHeightMeasureSpec)

        // 获取或创建用于存储子View边界的Rect对象
        // ...
    }

    // 计算自身尺寸
    // ...
}

而这一大段得出 MeasureSpec 的代码其实几乎是固定的,为此,Android 给我们提供了 measureChildWithMargins 方法,我们可以简化上述代码。同时,因为这个方法中会访问子 View 的外边距,所以要求子 View 的 LayoutParamsMarginLayoutParams 类型,否则会因强转失败抛出异常。所以,我们还要重写 generateLayoutParams 方法。

kotlin 复制代码
@SuppressLint("DrawAllocation")
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // 布局中已使用的空间
    var widthSizeUsed = 0
    var heightSizeUsed = 0

    // 遍历并测量子View
    for ((index, child) in children.withIndex()) {
        // 测量子 View
        measureChildWithMargins(
            child,
            widthMeasureSpec,
            widthSizeUsed,
            heightMeasureSpec,
            heightSizeUsed
        )

        // 获取或创建用于存储子View边界的Rect对象
        val childBounds =
            childrenBounds.getOrNull(index) ?: Rect().also { childrenBounds.add(it) }
    }

    // 计算自身尺寸
    val selfWidth = 0
    val selfHeight = 0
    setMeasuredDimension(selfWidth, selfHeight)
}


override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
    return MarginLayoutParams(context, attrs)
}

然后我们来完成单行布局的逻辑。

在单行布局中,widthSizeUsed 累加了所有子 View 的宽度,也恰好是下一个子 View 的左侧坐标 (left)。

heightSizeUsed 因为所有 View 都在同一行,所以其值始终为 0,也就是所有子 View 的顶部坐标 (top)。

另外,对于 TagLayout 自身尺寸的计算,在单行布局中,我们使用 widthSizeUsed(子 View 宽度之和)以及 maxHeight(最高子 View 的高度)。

kotlin 复制代码
@SuppressLint("DrawAllocation")
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // 布局中已使用的空间
    var widthSizeUsed = 0
    var heightSizeUsed = 0 // 单行布局中,此值始终为0
    
    // 总高度
    var maxHeight = 0

    // 遍历并测量子View
    for ((index, child) in children.withIndex()) {
        // 测量子 View
        measureChildWithMargins(
            child,
            widthMeasureSpec,
            widthSizeUsed, // 传入当前行已用宽度,作为子View的测量约束
            heightMeasureSpec,
            heightSizeUsed
        )

        // 保存子View的边界
        val childBounds =
            childrenBounds.getOrNull(index) ?: Rect().also { childrenBounds.add(it) }
        // 这里,widthSizeUsed 和 heightSizeUsed 恰好是当前子View的左上角坐标
        childBounds.set(
            widthSizeUsed,
            heightSizeUsed,
            widthSizeUsed + child.measuredWidth,
            heightSizeUsed + child.measuredHeight
        )
        
        // 累加子 View 的测量宽度
        widthSizeUsed += child.measuredWidth
        // 当前行最高子View的高度
        maxHeight = max(maxHeight, child.measuredHeight)
    }

    // 计算自身尺寸
    val selfWidth = widthSizeUsed
    val selfHeight = maxHeight
    setMeasuredDimension(selfWidth, selfHeight)
}

在布局中使用我们的 TagLayout,运行效果如下,可以看到超出屏幕的标签已经显示不出来了。

现在单行显示没问题了,接着完成多行显示。

在多行布局中,widthSizeUsed 现在只记录当前行的已用宽度。换行时,被重置为 0。heightSizeUsed 记录所有已布局完成的行的总高度。换行时,会累加上一行的最大高度,作为当前行的顶部坐标 (top)。

如何判断当前标签需要另起一行?

我们可以分两次测量,第一次测量给子 View 充足的宽度。如果它想要的宽度,加上当前行已使用的宽度,大于 TagLayout 的总宽度,那就说明需要另起一行了。

所以代码为:

kotlin 复制代码
@SuppressLint("DrawAllocation")
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // 已使用的尺寸
    var widthSizeUsed = 0 // 当前行的已用宽度
    var heightSizeUsed = 0 // 所有已完成行的总高度

    // 总高度和总宽度
    var maxHeight = 0
    var maxWidth = 0

    val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec)
    val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)

    // 遍历并测量子View
    for ((index, child) in children.withIndex()) {
        // 测量子View
        // 预测量
        measureChildWithMargins(
            child,
            widthMeasureSpec,
            0, // 🔍 先传0,让它测量出自己的期望宽度
            heightMeasureSpec,
            heightSizeUsed
        )

        // 判断预测量后的宽度是否会超出总宽度
        if (child.measuredWidth + widthSizeUsed > widthSpecSize && widthSpecMode != MeasureSpec.UNSPECIFIED) {
            // 如果超出,则需要换行
            // 更新总高度,累加上一行的高度
            heightSizeUsed = maxHeight
            // 重置行宽,新的一行从0开始
            widthSizeUsed = 0

            // 正式测量
            measureChildWithMargins(
                child,
                widthMeasureSpec,
                widthSizeUsed,
                heightMeasureSpec,
                heightSizeUsed
            )
        }

        // 保存子View的边界
        val childBounds = childrenBounds.getOrNull(index) ?: Rect().also { childrenBounds.add(it) }
        childBounds.set(
            widthSizeUsed,
            heightSizeUsed,
            widthSizeUsed + child.measuredWidth,
            heightSizeUsed + child.measuredHeight
        )

        // 更新状态
        widthSizeUsed += child.measuredWidth
        maxWidth = max(maxWidth, widthSizeUsed)
        maxHeight = max(maxHeight, heightSizeUsed + child.measuredHeight)
    }

    // 计算自身尺寸
    val selfWidth = maxWidth
    val selfHeight = maxHeight
    setMeasuredDimension(selfWidth, selfHeight)
}

给布局加个背景,方便查看尺寸大小。运行效果为:

你还可以使用 resolveSize 方法来修正布局的尺寸,以便响应父 View 传来的 MeasureSpec

kotlin 复制代码
// 计算自身尺寸
val selfWidth = maxWidth
val selfHeight = maxHeight
setMeasuredDimension(resolveSize(selfWidth, widthMeasureSpec), resolveSize(selfHeight, heightMeasureSpec))

例如,还是上述布局,宽度为 match_parent,高度为 wrap_content,运行效果将会是:

相关推荐
没有了遇见7 小时前
免费替代高德 / 百度!Android 原生定位 + GeoNames 离线方案:精准经纬度与模糊位置工具包
android
mucheni7 小时前
迅为RK3588开发板安卓串口RS485App开发-硬件连接
android
Akshsjsjenjd8 小时前
使用ansible的playbook完成以下操作
android·ansible
QING6188 小时前
使用扩展函数为 AppCompatTextView 提供了多段文本点击区域设置功能
android·kotlin·app
一条上岸小咸鱼9 小时前
Flutter 类和对象(一):类
android·kotlin
补三补四10 小时前
贝叶斯向量自回归模型 (BVAR)
android·算法·数据挖掘·数据分析·回归
future_studio11 小时前
如何用 Android 平台开发第一个 Kotlin 小程序
android·gitee