现在的需求这么花哨了吗,文本都能拼上自定义组件啦?

最近来了个需求,又是那种产品经理觉得研发轻轻松松就能搞定,但其实需要花费研发不少时间的活儿,这个需求是这样的,应用当中我们随处都可以看到文案与图片一起展示的场景,通常称之为富文本,这个富文本中的图片可能在文案的开头,可能在文案的中间或者在末尾,老牛马们肯定知道遇到这种需求最佳方案就是使用SpannableStringImageSpan

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)

这类代码多数会用在比如文案中展示个小图标或者小标签,有的标签里面会有些描述类的文案,就像我做的这个应用一样,然而这次产品经理突发奇想,感觉一个标签里面就展示一个文案太少了,想再加一个,并且俩种文案必须有较显眼的区分,于是最终定稿为如下面草图所示

草图不好看,大概类似于一个胶囊的样式,仔细观察,会发现如果需要绘制这个视图,需要注意以下几点

  1. 不同文案:左右两边都会展示文案
  2. 不同填充色:左右两边有不同的颜色填充
  3. 不规则形状:胶囊中间的斜线将胶囊分割成两部分
  4. 宽度自适应:胶囊的大小跟着里面文案的长度变化而改变

另外当胶囊边上的文案换行时候,换行后的文案左边必须需胶囊的左边对齐,这样就排除了简简单单使用布局去排版的方案,必须也得从使用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
}

上面是一些绘制时候需要用到的必需品,比如PathPaint之类的,然后就是些计算活儿了,开头说这个胶囊视图需要考虑自适应文本宽度,所以这个胶囊的宽度需要动态计算,计算过程放在如下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来获取这个异步数据,运行后得到效果如下

感觉不错,胶囊组件正好作为文案的一部分被替换了进去,但是这部分代码有个缺陷,缺陷就是没有控制胶囊的位置,导致胶囊会与文字不对齐,就比如我们将文字大小变大一些,就会是这样

可以看到这个时候胶囊底部还有很大一片空白,最理想的情况让胶囊与文字能够始终垂直居中,那么在CustomSpandraw函数中就要去计算胶囊在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这个以前没咋用到的类,还是有收获的,不说了,下班~撤退~

相关推荐
金疙瘩6 分钟前
TypeScript 联合类型与交叉类型详解
面试
哆啦美玲18 分钟前
Callback 🥊 Promise 🥊 Async/Await:谁才是异步之王?
前端·javascript·面试
liang_jy1 小时前
Android 窗口显示(一)—— Activity、Window 和 View 之间的联系
android·面试
用户2018792831671 小时前
快递分拣中心里的 LinkedList 冒险:从源码到实战的趣味解析
android
海的诗篇_1 小时前
前端开发面试题总结-vue2框架篇(三)
前端·javascript·css·面试·vue·html
工呈士2 小时前
TCP/IP 协议详解
前端·后端·面试
前端小巷子2 小时前
跨标签页通信(二):Service Worker
前端·面试·浏览器
comelong2 小时前
还有人不知道IntersectionObserver也可以实现懒加载吗
前端·javascript·面试
玲小珑2 小时前
Auto.js 入门指南(十五)脚本加密与安全防护
android·前端
花开月满西楼2 小时前
Android实例项目【智能家居系统】实现数据库登录注册+动画效果+网页跳转+短信发送!!!
android·数据库·智能家居