最近来了个需求,又是那种产品经理觉得研发轻轻松松就能搞定,但其实需要花费研发不少时间的活儿,这个需求是这样的,应用当中我们随处都可以看到文案与图片一起展示的场景,通常称之为富文本,这个富文本中的图片可能在文案的开头,可能在文案的中间或者在末尾,老牛马们肯定知道遇到这种需求最佳方案就是使用SpannableString
与ImageSpan
ini
val span = SpannableString("测试文案")
val drawable = resources.getDrawable(R.drawable.ic_launcher_foreground,null)
drawable.setBounds(0,0,drawable.intrinsicWidth,drawable.intrinsicHeight)
val imageSpan = ImageSpan(drawable)
span.setSpan(imageSpan,0,1,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
这类代码多数会用在比如文案中展示个小图标或者小标签,有的标签里面会有些描述类的文案,就像我做的这个应用一样,然而这次产品经理突发奇想,感觉一个标签里面就展示一个文案太少了,想再加一个,并且俩种文案必须有较显眼的区分,于是最终定稿为如下面草图所示
草图不好看,大概类似于一个胶囊的样式,仔细观察,会发现如果需要绘制这个视图,需要注意以下几点
- 不同文案:左右两边都会展示文案
- 不同填充色:左右两边有不同的颜色填充
- 不规则形状:胶囊中间的斜线将胶囊分割成两部分
- 宽度自适应:胶囊的大小跟着里面文案的长度变化而改变
另外当胶囊边上的文案换行时候,换行后的文案左边必须需胶囊的左边对齐,这样就排除了简简单单使用布局去排版的方案,必须也得从使用SpannableString
角度出发,而且也不能使用ImageSpan
,明显这种效果不是让设计师切个图就能搞定的,还是得老老实实自己去绘制,那么第一步开始绘制胶囊自定义视图
思路
这个视图如果考虑不充分的话,会直接上手先画一个圆角矩形,然后在矩形内部画一根斜线,不过接下来可能会有点蒙圈,因为会发现在填充颜色的时候,无法控制两边的颜色不超过斜线,所以要换种思考问题的方式,怎么做呢?咱又画了个草图
看懂啥意思了吗?就是把两个图形拼接起来组成一个胶囊,而要绘制两侧这样的图形,就可以使用Path
来绘制,大概思路就是这样,下面上代码
CapsuleView
kotlin
class CapsuleView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr){
private var slopeRatio: Float = 0.6f // 倾斜比例
private var leftFillColor: Int = Color.parseColor("#ff3b30")//左边填充色
private var rightFillColor: Int = Color.parseColor("#E6E6FA")//右边填充色
private var cornerRadius: Float = dp2Px(10f).toFloat()//圆角大小
private var xOffset = 0f//斜线顶部距离右侧的偏移量
private var mWidth = 0f//胶囊宽
private var mHeight = cornerRadius*2//胶囊高
}
首先创建几个必要的变量,其中slopeRatio
作为倾斜比例,用来控制绘制图形斜线时候的倾斜角度,其实说白了就是斜线底部距离右边的偏移量,而与之对应的xOffset
就是斜线顶部距离右侧的偏移量
ini
private val leftPath = Path()//左侧图形的Path
private val rightPath = Path()//右侧图形的Path
private val leftFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { //左侧填充色画笔
style = Paint.Style.FILL
color = leftFillColor
}
private val rightFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { //右侧填充色画笔
style = Paint.Style.FILL
color = rightFillColor
}
private val leftTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { //左侧文字画笔
color = Color.WHITE
textSize = sp2Px(10f).toFloat()
textAlign = Paint.Align.LEFT
isFakeBoldText = true
}
private val rightTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {//右侧文字画笔
color = Color.GRAY
textSize = sp2Px(8f).toFloat()
textAlign = Paint.Align.LEFT
isFakeBoldText = true
}
上面是一些绘制时候需要用到的必需品,比如Path
,Paint
之类的,然后就是些计算活儿了,开头说这个胶囊视图需要考虑自适应文本宽度,所以这个胶囊的宽度需要动态计算,计算过程放在如下setTextAndCal
函数中
kotlin
private var leftText = ""
private var rightText = ""
fun setTextAndCal(left:String,right:String){
this.leftText = left
this.rightText = right
val textWidth = leftTextPaint.measureText(leftText)
val rightTextWidth = rightTextPaint.measureText(rightText)
val total = textWidth+rightTextWidth
mWidth = total * 1.6f
xOffset = mWidth * (0.22f+rightText.length*0.05f)
invalidate()
}
这个函数接收两个参数,分别是左右两侧的文案,随后开始计算两个文案所占的总宽度,并且将计算出来的宽度乘以1.6倍作为胶囊的总宽度,由于宽度可能会发生变化,所以上面xOffset
也需要重新计算,下面开始画左侧部分
scss
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val width = mWidth
val height = mHeight
val slopeOffset = height * slopeRatio // 斜边偏移量
leftPath.reset()
val points = arrayOf(
PointF(0f, 0f), // 左上
PointF(width-xOffset, 0f), // 右上斜点
PointF(width-slopeOffset-xOffset, height), // 右下
PointF(0f, height) // 左下
)
leftPath.apply {
moveTo(points[0].x + cornerRadius, points[0].y)
// 左上圆角
quadTo(
points[0].x, points[0].y,
points[0].x, points[0].y + cornerRadius,
)
lineTo(points[3].x, points[3].y - cornerRadius)
// 左下圆角
quadTo(
points[3].x, points[3].y,
points[3].x + cornerRadius, points[3].y
)
lineTo(points[2].x, points[2].y)
lineTo(points[1].x, points[1].y)
close()
}
// 绘制填充和边框
canvas.drawPath(leftPath, leftFillPaint)
}
先找到上下左右四个点组成PointF
的数组,然后在绘制Path
的时候,以PointF
的点作为基准点来绘制圆角以及斜线,这样依样画葫芦地就能将右侧部分的代码写出来
scss
rightPath.reset()
val rightPoints = arrayOf(
PointF(width-xOffset, 0f),
PointF(width, 0f),
PointF(width, height),
PointF(width-slopeOffset-xOffset, height),
)
rightPath.apply {
moveTo(rightPoints[0].x,rightPoints[0].y)
lineTo(rightPoints[1].x-cornerRadius,rightPoints[1].y)
quadTo(rightPoints[1].x,rightPoints[1].y,rightPoints[1].x,rightPoints[1].y+cornerRadius)
lineTo(rightPoints[2].x,rightPoints[2].y-cornerRadius)
quadTo(rightPoints[2].x,rightPoints[2].y,rightPoints[2].x-cornerRadius,rightPoints[2].y)
lineTo(rightPoints[3].x,rightPoints[3].y)
close()
}
canvas.drawPath(rightPath, rightFillPaint)
看起来挺复杂的形状到最后画起来也没多少代码,最后将文字绘制进去
scss
val baselineY = height / 2f - (leftTextPaint.descent() + leftTextPaint.ascent()) / 2f
canvas.drawText(leftText, points[0].x+cornerRadius, baselineY, leftTextPaint)
val rightBaselineY = height / 2f - (rightTextPaint.descent() + rightTextPaint.ascent()) / 2f
canvas.drawText(rightText, width-xOffset, rightBaselineY, rightTextPaint)
这个胶囊组件就完成了,写点代码验证下效果
java
val cap1 = findViewById<CapsuleView>(R.id.cap1)
val cap2 = findViewById<CapsuleView>(R.id.cap2)
cap1.setTextAndCal("哈哈哈","呵呵")
cap2.setTextAndCal("哈哈","略略略略")

当看到胶囊宽度可以按照文字多少来自适应的时候,就知道我们这个组件算是完成了
ReplacementSpan
第一件事开发组件已经完成,现在来做第二件事,与文案一起拼接,由于与文案拼接的对象变成了自定义组件,所以不能再用ImageSpan
了,而是得用ReplacementSpan
,使用替换的方式将胶囊塞到文案中,先创建一个ReplacementSpan
的子类CustomSpan
kotlin
class CustomSpan(private val view: View, private val width: Int = 0, private val height: Int = 0) :
ReplacementSpan() {
override fun getSize(
paint: Paint,
text: CharSequence?,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
return width
}
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
canvas.save()
canvas.translate(x, top.toFloat())
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
view.draw(canvas)
canvas.restore()
}
}
ReplacementSpan
有两个主要的函数,getSize
用来计算替换进来的视图所占的宽度,draw
函数则是做一些绘制工作,比如定义一些绘制规则,顶部对齐还是底部对齐,另外还注意到CustomSpan
接收两个参数,第一个参数View
就是要替换进来的组件,第二个参数width
是替换进来组件所占的宽度,第三个参数height
是组件的宽度,但是想到我们胶囊的宽高都是内部计算的,外界并不知道,所以需要暴露出来一个方法来获取胶囊的宽高,在胶囊组件中新增如下代码
kotlin
private var onWidthReceive:((Float,Float)->Unit)? = null
fun setWidthReceived(widthCallback:(Float,Float)->Unit){
this.onWidthReceive = widthCallback
}
新增setWidthReceived
函数注册一个回调宽高的函数变量,然后在setTextAndCal
函数中将计算出来的宽高暴露出去
kotlin
fun setTextAndCal(left:String,right:String){
this.leftText = left
this.rightText = right
val textWidth = leftTextPaint.measureText(leftText)
val rightTextWidth = rightTextPaint.measureText(rightText)
val total = textWidth+rightTextWidth
mWidth = total * 1.6f
xOffset = mWidth * (0.22f+rightText.length*0.05f)
onWidthReceive?.invoke(mWidth,mHeight)
invalidate()
}
这样就能拿到宽高了,在上层Activity
中试验下效果
scss
val latch = CountDownLatch(1)
label.text = SpannableStringBuilder().apply {
var mWidth = 0
var mHeight = 0
append("# 文案文案文案文案文案文案文案文案文案文案文案文案文案文案文案")
val cv = CapsuleView(this@CoffeeActivity).apply {
setWidthReceived { width,height ->
mWidth = width.toInt()
mHeight = height.toInt()
latch.countDown()
}
setTextAndCal("哈哈哈","呵呵")
}
latch.await()
val span = CustomSpan(cv, mWidth,mHeight)
setSpan(span, 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
由于宽高都是异步操作得到的数据,所以在整个同步过程中需要用一个CountDownLatch
来获取这个异步数据,运行后得到效果如下
感觉不错,胶囊组件正好作为文案的一部分被替换了进去,但是这部分代码有个缺陷,缺陷就是没有控制胶囊的位置,导致胶囊会与文字不对齐,就比如我们将文字大小变大一些,就会是这样
可以看到这个时候胶囊底部还有很大一片空白,最理想的情况让胶囊与文字能够始终垂直居中,那么在CustomSpan
的draw
函数中就要去计算胶囊在Y轴上的偏移量,计算的方式就是bottom-height/2
kotlin
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
canvas.save()
val offsetY = (bottom - height) / 2f
canvas.translate(x, offsetY)
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
view.draw(canvas)
canvas.restore()
}

将偏移量计算出来后我们看到了效果已经达到了我们预期想要的,现在无论如何改变文字大小,或者胶囊大小,都可以保证自定义视图与文字始终保持垂直居中
总结
这个需求到这算是做完了,真的是费劲,又要自定义视图又要算位移的,不过也认识了ReplacementSpan
这个以前没咋用到的类,还是有收获的,不说了,下班~撤退~