被迫内卷之 Android 自定义View进阶(一)

1,529 阅读4分钟

背景

家人们,太卷啦,谁懂呀,今天老板给让我们实现如下效果的一个东东。 WechatIMG13.jpeg 我看的第一眼,这不是很简单吗?作为一个优秀的Androider,这种事情当然是让设计师直接给我一张完整的图,然后放到页面就好了,完工!

但是,当我让设计师这样子给我切图的时候,做iOS开发的小伙伴问我:你这样做的话要是后续内容有更新咋办呢?
我:那还不简单,换张图就好了呀。
iOS的小伙伴: 那上面有两个可以点击的地方咋办?
我:我。。。凉拌
iOS的小伙伴:小张(设计师)把上面的左边中间的图标给我,右下角的图给我,其他地方我自己画了。
我的内心OS:能不能不要这么卷呀,直接放张图多好,要啥按钮。。。。
这个时候产品经理说:这个内容随时都要变的,要后端可以直接控制这个内容。
我:。。。。要不你来?

当然这句话怂怂的我没敢说出来。这个效果表面看起来没啥难度,但是其实暗藏玄🐔。

  1. 左边的“会员特权”那一块,整体背景是一个不规则图像,并且是从左到右的一个渐变效果。右边的背景整体是一个带圆角的纯色背景,右下角有个图标。
  2. 右边的背景倒是不复杂,但是文本后面可能存在(也可能不存在)一个可以点击的文本按钮,当文本最后一行可以按钮共存(长度能放得下两个内容)的时候,按钮在文本最后一行后面(如第2点),当最后一行长度不能和按钮共存的时候,按钮需要换行(如第5点)。

有的小伙伴可能说,这有啥难的,第2点上 FlexboxLayout 不就好了。但是仔细想想 FlexboxLayout 其实不行。因为 FlexboxLayout 计算的时候控件整个的宽度,而不是 TextView 最后一行的宽度。因此 FlexboxLayout 没有办法把文本按钮放到 TextView 的最后一行,除非 TextView 只有一行。

没办法了,只能选择自定义 View 来突破。我们来把上面的问题逐个突破。作为一名合格的 Androider ,我们不惹事,但是我们不能怕事儿。

整体思路

  1. 最外层的自定义View继承 ConstraintLayout ,通过重写其 onDraw 方法自定义绘制其中的左右部分的背景,左右部分的背景均通过 Canvas.drawPath() 方法进行绘制,所以需要提前准备好两个部分的Path。
  2. 左半部分的图标通过TextView + drawableTop 放到自定义的 ConstraintLayout 的左边,右下角的图标同理通过 ImageView 放上去,为减少工作量,这两个地方的图标+文字均在 Xml 布局时放上去。
  3. 右边的文本内容+文本按钮整体为一个 RecyclerView,RecyclerView 的 Item 则通过自定义 ViewGroup 实现对位置进行随心所欲的控制。

实现最外层的自定义View

作为一个996的Androider,看图写 Path 简直是信手拈来。而我,在 ChatGPT 的帮助下,只用了2个小时就把 Path 给搞定了,是不是很厉害。(此处应有狗头)由于没有什么技术难度,我就直接贴代码了。

// 构建左半部分的背景的 Path
val triangleWidth = 6.dp
val triangleHeight = 16.dp
val radius = 16.dp
val leftRectWidth = 100.dp

shaderPath.reset()
shaderPath.moveTo(radius, 0f)
// 最上面的那条线
shaderPath.lineTo(leftRectWidth, 0f)
// 最后边的线+三角形
shaderPath.lineTo(leftRectWidth, (viewHeight - triangleHeight) / 2f)
shaderPath.lineTo(leftRectWidth + triangleWidth, viewHeight / 2f)
shaderPath.lineTo(leftRectWidth, (viewHeight + triangleHeight) / 2f)

// 剩下的线+圆角
shaderPath.lineTo(leftRectWidth, viewHeight.toFloat())
shaderPath.lineTo(radius, viewHeight.toFloat())
shaderPath.arcTo(0f, viewHeight - radius, radius, viewHeight.toFloat(), 90f, 90f, false)
shaderPath.lineTo(0f, radius)
shaderPath.arcTo(0f, 0f, radius, radius, 180f, 90f, false)

左边的部分搞定了,该右边了。

val radius = 16.dp
val leftRectWidth = 100.dp

rightPath.reset()
rightPath.moveTo(leftRectWidth, 0f)
rightPath.lineTo(viewWidth.toFloat() - radius, 0f)
// 右上圆角
rightPath.arcTo(
    viewWidth.toFloat() - radius,
    0f,
    viewWidth.toFloat(),
    radius,
    -90f,
    90f,
    false
)
rightPath.lineTo(viewWidth.toFloat(), viewHeight.toFloat() - radius)
// 右下圆角
rightPath.arcTo(
    viewWidth.toFloat() - radius,
    viewHeight.toFloat() -radius,
    viewWidth.toFloat(),
    viewHeight.toFloat(),
    0f,
    90f,
    false
)
rightPath.lineTo(100.dp, viewHeight.toFloat())
rightPath.close()

我们需要在 onSizeChange 方法中调用以上代码重新构建以上两个 Path,这样我们的自定义 View 就可以随着用户屏幕的变化而随之变化。
除此之外,为了实现渐变效果,我们还应该创建一个 Shader。

private fun createShader(): Shader {
    val startColor = Color.parseColor("#FEDD83")
    val endColor = Color.parseColor("#F2CA5C")
    return LinearGradient(
        0f,
        0f,
        106.dp,
        0f,
        startColor,
        endColor,
        Shader.TileMode.CLAMP
    )
}

OK,万事俱备,只欠东风。接下来我们只需要在 onDraw 里面把这两个 Path 画出来就好啦。

override fun onDraw(canvas: Canvas) {
    // 先绘制右边的Path
    canvas.drawPath(rightPath, paint)

    // 渐变效果的Shader
    paint.shader = shader
    // 绘制左边的Path
    canvas.drawPath(shaderPath, paint)
    // 渐变效果取消,防止重复调用时影响到右边的Path
    paint.shader = null

}

好了,我们来运行看一哈。咦?啥也没有!为啥会这样?我几个小时的努力呀!!!别慌,我来问下 ChatGPT。原来ViewGroup以及其子类为了更好的性能,默认情况下都不会触发其 onDraw 方法,那样咋办呢?我们可以在自定义 View 的构造函数中调用 setWillNotDraw(false) 来接触这个限制。加上这行代码之后我们在来看看效果。

WechatIMG14.jpeg 嗯嗯,没毛病,背景图已经完工。接下来就要自定义右边那个类似 FlexboxLayout 的控件了。由于篇幅关系,我把接下来的实现放到了下一篇《被迫内卷之 Android 自定义View进阶(二)》