直播间自定义公屏视图的升级之路(View版)

1.前言

最近的版本呢,产品更新了一个直播间的需求,原本直播间的公屏聊天内容基本只展示粉丝等级、会员等级等一两个基本的标签,新的版本呢又加入了很多勋章类型的标签,需要一起展示出来(搞不懂为啥整这么多)! 区别大概就如下图所示(这些等级标签及勋章看着是不是很眼熟):
Snipaste_2023-06-25_16-01-18.png
Snipaste_2023-06-25_16-00-52.png
简单整理了下,大致的区别就是:

  • 旧版的设计只有固定的一两个标签,然后跟上用户发送的文字等信息;
  • 新版的设计要求带不固定数量的标签,少的话可能一两个,多的话标签可能还需要换行,然后跟上用户发送的文字等信息;

那么从旧版到新版需要经历哪些修改呢,一起来复习下自定义View的过程吧。

注: 为了简化处理,所有的标签自定义View都使用图片(ImageView)代替了。

2.旧版设计的分析

先来看下旧版是怎么处理的,旧版UI的蓝图如下:
Snipaste_2023-06-25_16-50-25.png
可以看到,蓝图中标签视图(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蓝图,根据需求,多个勋章要顺序摆放下来,如果过长还要换行处理,如下所示。相比旧版固定的一两个标签来说,增加了一丢丢难度。
Snipaste_2023-06-25_16-51-03.png
这个时候旧版的功能完全无法满足我们现在的需求了,现在的标签数量不固定,可能没有,可能多到换行,所以我们只能通过自定义布局去搞定了。

具体要怎么做呢,再仔细琢磨下,标签类的控件其实都是流式布局,所有标签按照流式布局顺序摆放即可,需要换行则处理换行,但是最后一个TextView就比较特殊了,如果也按照流式布局处理的话,如下蓝图所示:
Snipaste_2023-06-25_21-05-04.png
当文本长度较短且剩下空间正好够的时候,还是刚好能达到效果的。但是当文本长度过长的时候效果肯定是下层蓝图这样的效果,文本直接新起一行,标签后面一大段的空间就都浪费了。

所以呢,这个时候我们就结合一下旧版的设计,将最后一行的几个标签所占的宽度计算出来,然后给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的情况,其他暂不考虑,以减少示例的复杂程度,需要实现的蓝图如下所示:
Snipaste_2023-06-27_15-06-49.png

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版的还远吗?

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值