背景
前文实现了如下示例图的背景绘制,如果没有看过前文的建议先看前文了解一下背景。《被迫内卷之 Android 自定义View进阶(一)》
本文的目标是实现右边文本的排版效果。文本的排版效果需类似 FlexboxLayout 的效果,只不过 FlexboxLayout 是根据 View 宽度来判断 N 个 View 摆放到同一行还是换行,而我们需要实现的是判断一个 View 能否摆放到一段文本的最后一行,如果可以则放在其最后一行,否则换行摆放。
整体思路
当然实现方式是多种多样的。本次的实现方式是右边的文本整体放入一个不可滚动的 RecyclerView 中,每一个小点是 RecyclerView 的一个 Item。这个 Item 的最外层布局我们采用自定义 ViewGroup 的方式进行实现。文本的测量以及绘制放在自定义的 ViewGroup 的中,而另一个可点击的按钮由布局文件中自定义传入。
文本的测量绘制
文本的绘制我们其实也可以有很多种选择,比如我们可以通过 Paint.breakText() 去精准控制每一行的宽度,然后通过一行一行的 drawText() 去绘制。不过这个一般是用于做图文混排的“大招”,平常不轻易使用的。从时间成本方面考虑,最契合我们此次需求的是 StaticLayout。部分同学可能对这个类比较陌生,但是其实这个类我们间接的用过很多次了,因为 TextView 的文本测量绘制实际上就是借助这个 StaticLayout 实现的。借助 StatictLayout 的功能,我们可以很轻易的实现一个“丐版” TextView。
- 将文本传入 StaticLayout 后,通过 StaticLayout.getHeight(),可以很方便的测量出这个文本的高度
- 通过 StaticLayout.getLineWidth() 我们也可以很方便的知道最后一行的高度
- 需要绘制的时候,之后直接使用 StaticLayout.draw(Canvas) ,直接绘制出传入的文本,对于我们此次的需求来说简直不要太好用。
可点击按钮摆放思路
一般情况下自定义 ViewGroup 和普通的自定义 View 有一些区别。
- 如果是普通的自定义 View,测量的时候只需要考虑自己就行了,自定义 ViewGroup 还需要考虑其子View的宽度和高度来决定自己宽高。
- 自定义 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 中看下效果。
到这里我们的需求实现已完成99%。接下来需要做的就是把左边的文本 + 图片,以及右下角的图片在 XML 中放上去。由于没啥难度,这里就不贴代码了。完整代码已放到 github。