被迫内卷之 Android 自定义View进阶(二)

1,951 阅读4分钟

背景

前文实现了如下示例图的背景绘制,如果没有看过前文的建议先看前文了解一下背景。《被迫内卷之 Android 自定义View进阶(一)》 WechatIMG13.jpeg 本文的目标是实现右边文本的排版效果。文本的排版效果需类似 FlexboxLayout 的效果,只不过 FlexboxLayout 是根据 View 宽度来判断 N 个 View 摆放到同一行还是换行,而我们需要实现的是判断一个 View 能否摆放到一段文本的最后一行,如果可以则放在其最后一行,否则换行摆放。

整体思路

当然实现方式是多种多样的。本次的实现方式是右边的文本整体放入一个不可滚动的 RecyclerView 中,每一个小点是 RecyclerView 的一个 Item。这个 Item 的最外层布局我们采用自定义 ViewGroup 的方式进行实现。文本的测量以及绘制放在自定义的 ViewGroup 的中,而另一个可点击的按钮由布局文件中自定义传入。

文本的测量绘制

文本的绘制我们其实也可以有很多种选择,比如我们可以通过 Paint.breakText() 去精准控制每一行的宽度,然后通过一行一行的 drawText() 去绘制。不过这个一般是用于做图文混排的“大招”,平常不轻易使用的。从时间成本方面考虑,最契合我们此次需求的是 StaticLayout。部分同学可能对这个类比较陌生,但是其实这个类我们间接的用过很多次了,因为 TextView 的文本测量绘制实际上就是借助这个 StaticLayout 实现的。借助 StatictLayout 的功能,我们可以很轻易的实现一个“丐版” TextView。

  1. 将文本传入 StaticLayout 后,通过 StaticLayout.getHeight(),可以很方便的测量出这个文本的高度
  2. 通过 StaticLayout.getLineWidth() 我们也可以很方便的知道最后一行的高度
  3. 需要绘制的时候,之后直接使用 StaticLayout.draw(Canvas) ,直接绘制出传入的文本,对于我们此次的需求来说简直不要太好用。

可点击按钮摆放思路

一般情况下自定义 ViewGroup 和普通的自定义 View 有一些区别。

  1. 如果是普通的自定义 View,测量的时候只需要考虑自己就行了,自定义 ViewGroup 还需要考虑其子View的宽度和高度来决定自己宽高。
  2. 自定义 ViewGroup 需要重写 onLayout() 来“手动”摆放子 View 的位置。

所以自定义 ViewGroup 要麻烦一点,但是也给我们提供了足够的灵活度来实现我们的需求。我们可以在 onMeasure() 中构建 StaticLayout ,然后根据其提供的高度,加上其最后一行的宽度,以及子 View 的宽度知道这个子 View 是应该摆放在其最后一行的后面还是其下一行。为了方便起见,我们这个自定义 View 仅允许最多一个子 View。

代码实现

测量

测量是我们实现此功能的关键步骤。

// 辅助文本测量绘制用的 StaticLayout
private lateinit var staticLayout: StaticLayout
// 可点击的按钮是否放到文本的下一行
private var hasExtraLine = false
// 绘制文本用的paint
private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
    textSize = 13.sp
}
// 文本
var text: CharSequence = ""
    set(value) {
        if (field != value) {
            field = value
            requestLayout()
        }
    }
    
// 测量的具体实现
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // 由于本次需求测量方式只可能是固定的宽度,所以直接获取其宽度作为本控件的宽度
    val width = MeasureSpec.getSize(widthMeasureSpec)
    // 构建StaicLayout对象
    staticLayout = StaticLayout.Builder
        .obtain(text, 0, text.length, textPaint, width)
        .build()
    if (childCount > 0) {
        // 最后一行宽度
        val lastLineWidth = staticLayout.getLineWidth(staticLayout.lineCount - 1)
        val childView = getChildAt(0)
        if (childView.visibility == View.GONE) {
            // 子 View 不可见,按照无子View处理
            hasExtraLine = false
            setMeasuredDimension(
                width + paddingStart + paddingEnd,
                staticLayout.height + paddingTop + paddingBottom
            )
            return
        }
        
        // 测量子 View,直接按照整个父 View 的宽高去测量
        measureChildWithMargins(childView, widthMeasureSpec, 0, heightMeasureSpec, 0)
        val childWidth = childView.measuredWidth
        val childViewLp = childView.layoutParams as MarginLayoutParams
        if (lastLineWidth + childWidth + childViewLp.marginStart + childView.marginEnd > width) {
            // 无法摆放到同一行,所需高度则为 StaticLayout 的高度 + 可点击View的高度 + Padding
            hasExtraLine = true
            setMeasuredDimension(
                width + paddingStart + paddingEnd,
                staticLayout.height + paddingTop + paddingBottom + childView.measuredHeight
                        + childViewLp.topMargin + childViewLp.bottomMargin
            )
        } else {
            // 可以摆放到同一行,所需高度则为 StaticLayout 的高度 + Padding
            hasExtraLine = false
            setMeasuredDimension(
                width + paddingStart + paddingEnd,
                staticLayout.height + paddingTop + paddingBottom
            )
        }
    } else {
        // 无子 View
        hasExtraLine = false
        setMeasuredDimension(
            width + paddingStart + paddingEnd,
            staticLayout.height + paddingTop + paddingBottom
        )
    }
}

上面的代码其实并不是十分严谨,比如没有判断可点击的View是否有可能比最后一行高,但是已经可以满足此次需求了,因为在设计图上,可点击的按钮的文本要比前面的文本小一点的。

布局

其次我们需要在 onLayout() 中摆放那个可点击的View。

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    if (childCount <= 0 || getChildAt(0).visibility == View.GONE) {
        return
    }
    val childView = getChildAt(0)
    if (hasExtraLine) {
        // 有多余的一行,childView在StaticLayout的下面
        val childViewLp = childView.layoutParams as MarginLayoutParams
        childView.layout(
            paddingStart + childViewLp.marginStart,
            staticLayout.height + childViewLp.topMargin,
            childView.measuredWidth + paddingStart + childViewLp.marginStart,
            staticLayout.height + childView.measuredHeight + childViewLp.topMargin
        )
    } else {
        // 没有多余的一行,childView摆放到staticLayout的最后一行的最后
        val childViewLp = childView.layoutParams as MarginLayoutParams
        val childViewLeft = staticLayout.getLineWidth(staticLayout.lineCount - 1)
            .roundToInt() + paddingStart + childViewLp.marginStart

        val childViewTop =
            staticLayout.height - childView.measuredHeight + childViewLp.topMargin
        childView.layout(
            childViewLeft,
            childViewTop,
            childViewLeft + childView.measuredWidth,
            childViewTop + childView.measuredHeight
        )
    }
}

绘制

有了 StaticLayout 的帮助,绘制就显得很简单了。

override fun onDraw(canvas: Canvas) {
    staticLayout.draw(canvas)
}

当然也不能忘了在构造方法中调用setWillNotDraw(false),不然无法正确绘制出所需效果。

其他细节

我们的需求只需要一个子 View 就可以了,所以我们可以在布局填充完成 onFinishInflate() 判断子 View 的个数,如果子 View 的数量大于1,则抛出异常。

override fun onFinishInflate() {
  super.onFinishInflate()
  if (childCount > 1) {
    throw IllegalArgumentException("VipPrivilegesItemView can only have 1 children")
  }
}

当然为了严谨,可以在 addView 中也判断一下子 View 个数。 由于我们在测量的时候使用了 MarginLayoutParams ,所以需要重写 generateLayoutParams() 方法,使其返回 MarginLayoutParams,不然子 View 的 margin 属性不会生效。

效果

自定义 ItemView 完成后,我们来把这个放到 RecyclerView 中看下效果。 WechatIMG244.jpeg 到这里我们的需求实现已完成99%。接下来需要做的就是把左边的文本 + 图片,以及右下角的图片在 XML 中放上去。由于没啥难度,这里就不贴代码了。完整代码已放到 github