目录
- 1.前言
- 2.旧版设计的分析
- 3.新版设计的分析
- 4.代码实现(View版本)
- 4.1.流式布局的实现
- 4.1.1.测量
- 4.1.2.布局
- 4.2针对需求优化流式布局
- 4.2.1.测量
- 4.2.2.布局
- 5.总结
1.前言
最近的版本呢,产品更新了一个直播间的需求,原本直播间的公屏聊天内容基本只展示粉丝等级、会员等级等一两个基本的标签,新的版本呢又加入了很多勋章类型的标签,需要一起展示出来(搞不懂为啥整这么多)! 区别大概就如下图所示(这些等级标签及勋章看着是不是很眼熟):
简单整理了下,大致的区别就是:
- 旧版的设计只有固定的一两个标签,然后跟上用户发送的文字等信息;
- 新版的设计要求带不固定数量的标签,少的话可能一两个,多的话标签可能还需要换行,然后跟上用户发送的文字等信息;
那么从旧版到新版需要经历哪些修改呢,一起来复习下自定义View的过程吧。
注: 为了简化处理,所有的标签自定义View都使用图片(ImageView)代替了。
2.旧版设计的分析
先来看下旧版是怎么处理的,旧版UI的蓝图如下:
可以看到,蓝图中标签视图(ImageView)和文本视图(TextView)是重叠在一起的,然而事实也是这样,在旧版的处理中,设置数据后需要手动测量所有标签视图的宽度,测量完毕后让文本缩进所有标签宽度的长度即可。
那么如何做到文本缩进的效果呢?对滴,通过对SpannableString设置相应的Span即可,它支持设置很多类型,常用的如下所示(未列举完全):
- ForegroundColorSpan
设置文字颜色
- BackgroudColorSpan
设置文字背景颜色
- ClickableSpan
设置点击效果
- URLSpan
设置超链接效果,点击跳转浏览器
- StrikethroughSpan
设置文字删除线效果
- UnderlineSpan
设置文字下划线效果
- ImageSpan
设置文字中插入图片的效果
- LeadingMarginSpan
设置文字缩进效果
添加文本缩进功能的伪代码则如下所示:
val marginWidth = 1000 // 设置缩进的长度(也就是所有标签测量出来的长度)
val marginSpan = LeadingMarginSpan.Standard(marginWidth, 0)
val spannableString = SpannableString("小青龙")
spannableString.setSpan(marginSpan, 0, 0, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.text = spannableString
3.新版设计的分析
再来看下新版的UI蓝图,根据需求,多个勋章要顺序摆放下来,如果过长还要换行处理,如下所示。相比旧版固定的一两个标签来说,增加了一丢丢难度。
这个时候旧版的功能完全无法满足我们现在的需求了,现在的标签数量不固定,可能没有,可能多到换行,所以我们只能通过自定义布局去搞定了。
具体要怎么做呢,再仔细琢磨下,标签类的控件其实都是流式布局,所有标签按照流式布局顺序摆放即可,需要换行则处理换行,但是最后一个TextView就比较特殊了,如果也按照流式布局处理的话,如下蓝图所示:
当文本长度较短且剩下空间正好够的时候,还是刚好能达到效果的。但是当文本长度过长的时候效果肯定是下层蓝图这样的效果,文本直接新起一行,标签后面一大段的空间就都浪费了。
所以呢,这个时候我们就结合一下旧版的设计,将最后一行的几个标签所占的宽度计算出来,然后给TextView设置一个MarginSpan,然后重新测量其宽度和高度,最后摆放的时候同最后一行标签的顶部和左端对齐布局即可。
4.代码实现(View版本)
分析完毕后我们的思路就大致定下来了,先实现流式布局,针对最后一个TextView需要重新优化再处理。
4.1.流式布局的实现
流式布局的实现,可能对大家都不陌生了,这里简要罗列下几个基础的步骤(去除了margin和padding等其他复杂的逻辑,只留下了主干代码),首先自定义MyFlowLayout继承自ViewGroup。
4.1.1.测量
在onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int)方法中,调用measureChild()方法,循环测量子View,并且每次累加当前行子View的宽度lineWidth,记录当前行子View的最高高度lineHeight。因为我们要实现流式布局,所以当下一个子View累计的宽度超出了父容器的宽度时,那么就需要进行换行处理了,如下代码中第19行的注释。此时我们需要记录上一行子View的所占的实际宽度width,以及高度height。
每次测量完一行后,width要取所有行中的最大值lineWidth,height需要累加每行子View中的最高值lineHeight,以此类推,直到所有子View测量完毕,简要代码如下所示:
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
var width = 0
var height = 0
var lineWidth = 0
var lineHeight = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
measureChild(child, widthMeasureSpec, heightMeasureSpec)
val childWidth = child.measuredWidth
val childHeight = child.measuredHeight
// 换行操作
if (lineWidth + childWidth > widthSize) {
height += lineHeight
lineWidth = childWidth
lineHeight = childHeight
} else {
lineWidth += childWidth
lineHeight = lineHeight.coerceAtLeast(childHeight)
}
width = width.coerceAtLeast(lineWidth)
}
// 加上最后一行的高度
height += lineHeight
setMeasuredDimension(
if (widthMode == MeasureSpec.EXACTLY) widthSize else width,
if (heightMode == MeasureSpec.EXACTLY) heightSize else height
)
测量的最后一步是确定父容器的大小,当我们调用 setMeasuredDimension() 方法时,就是在告诉父布局或容器我们自定义布局的实际尺寸。可以将这个方法类比为:一个画家完成绘画后,将画作的实际尺寸告知展览场所,以便场所为画作提供正确的展示空间。
4.1.2.布局
接下来呢就开始布局了,在onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) 函数中,对子View调用layout(int l, int t, int r, int b)函数挨个摆放已经测量好的子View,第一个子View的位置从(left = 0, top = 0, right = 当前子view的宽度, bottom = 当前子View的高度)开始摆放,后续子View的位置就是从上一个位置的末尾开始摆放(left = 累计子View的宽度, top = 0, right = 累计子View的宽度 + 当前子view的宽度, bottom=当前子View的高度)。
以此类推,当摆放下一个子View的宽度超过父容器的宽度时,则进行换行处理,此时left = 0,top = 上一行子View的最大高度,简要代码如下所示:
var childLeft = 0
var childTop = 0
val width = right - left
var lineWidth = 0
var lineHeight = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
val childWidth = child.measuredWidth
val childHeight = child.measuredHeight
// 换行操作
if (lineWidth + childWidth > width) {
childLeft = 0
childTop += lineHeight
lineWidth = 0
lineHeight = childHeight
}
val childRight = childLeft + childWidth
val childBottom = childTop + childHeight
child.layout(
childLeft,
childTop,
childRight,
childBottom
)
childLeft += childWidth
lineWidth += childWidth
lineHeight = lineHeight.coerceAtLeast(childHeight)
}
4.2针对需求优化流式布局
接下来就是在流式布局上的优化过程了,这里我们只针对最后一个子View是TextView的情况,其他暂不考虑,以减少示例的复杂程度,需要实现的蓝图如下所示:
4.2.1.测量
所以上述优化的需求,我们分析后统一的处理方式就是:在测量TextView前,先将最后一行标签的长度测量出来lineWidth,然后给TextView设置一个MarginSpan,长度就是lineWidth,最后再测量这个TextView。
给TextView添加MarginSpan的代码如下所示:
private var marginSpan: LeadingMarginSpan? = null
private fun addMarginSpanToTextView(textView: TextView, lineWidth: Int) {
val oldText = textView.text
val spannableString = if (oldText is SpannableString) {
oldText
} else {
SpannableString(oldText ?: "")
}
// 如果之前有设置过marginSpan的话先清除掉
if (marginSpan != null) {
spannableString.removeSpan(marginSpan)
}
// 设置新的marginSpan
marginSpan = LeadingMarginSpan.Standard(lineWidth, 0)
spannableString.setSpan(marginSpan, 0, 0, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.text = spannableString
}
设置完MarginSpan后的测量代码简要如下:
// 省略重复代码
...
for (i in 0 until childCount) {
val child = getChildAt(i)
// 先添加一个MarginSpan
if (child is TextView) {
addMarginSpanToTextView(child, lineWidth)
}
measureChild(child, widthMeasureSpec, heightMeasureSpec)
// 省略重复代码
...
if (child is TextView) {
lineWidth = childWidth
lineHeight = childHeight
} else {
// 换行操作
// 省略重复代码
....
}
}
// 省略重复代码
....
4.2.2.布局
布局的时候,前面所有的标签都正常按照流式布局摆放即可,当摆放到最后一个TextView的时候,我们将其直接将其从最后一行标签的起点位置左端对齐,顶部对齐摆放即可,简要代码如下所示:
// 省略重复代码
....
for (i in 0 until childCount) {
// 省略重复代码
....
// 如果是TextView的话直接从头开始摆放
if (child is TextView) {
childLeft = paddingLeft
} else {
// 换行操作
// 省略重复代码
....
}
// 省略重复代码
....
}
5.总结
经过上述步骤之后我们已经实现了一个简单的升级版的流式布局,他支持对最后一个TextView设置MarginSpan的处理,以使得整条公屏的内容更加紧凑。
但是呢,我们还有很多的内容未添加支持,例如margin、padding的处理,子View间横向间距、竖向间距的处理,每行之间子View的对齐方式处理,每列之间子View的对齐方式处理等等,相信剩下的内容难不倒你我,冲啊,去实现它。
最后的最后,View版的自定义效果实现了,Compose版的还远吗?