布景

前文完成了如下示例图的布景制作,如果没有看过前文的建议先看前文了解一下布景。《被逼内卷之 Android 自定义View进阶(一)》

被迫内卷之 Android 自定义View进阶(二)
本文的目标是完成右边文本的排版作用。文本的排版作用需类似 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 中看下作用。

被迫内卷之 Android 自定义View进阶(二)
到这儿咱们的需求完成已完成99%。接下来需求做的就是把左面的文本 + 图片,以及右下角的图片在 XML 中放上去。因为没啥难度,这儿就不贴代码了。完整代码已放到 github。