有时候,UI可能会设计一个效果,需要我们在View的左上角加上一个横幅,并在横幅上添加文字显示,例如下面这张图的效果:

image-20200902154545916

紫色部分就是我们所说的“横幅”。这个效果如何实现呢?两种方案:

  • UI切图
  • 自定义View实现

UI切图有一些不好的地方,一是如果横幅的文字时动态变化的,那需要对应多张切图;二是切图无疑会增加APK的体积。因此我们选择「自定义View实现」。

一、明确为「谁」而自定义

如果我们编写一个自定义View,只是为了给自己的App使用,那么可以考虑得简单一些,不需要对外提供过多的自定义属性,也不需要考虑太多的兼容适配问题;如果是需要公开提供给广大开发者使用,那么就需要考虑提供更多的自定义属性方便大家使用。

在这里,我决定采取后者的方式来定义这个边角横幅View。

二、是自定义View还是自定义ViewGroup

考虑到在各个项目中,需要加这种边角横幅的View或ViewGroup的布局差异都是非常大的,因此我们应该提供一个自定义ViewGroup,用户给需要边角横幅的View或ViewGroup套上我们的ViewGroup即可,至于用户要给什么样的内容布局加上边角横幅,我们不必关心。

综上,最适合的就是自定义ViewGroup,继承自FrameLayout

三、需要暴露哪些属性

除了在代码中提供对应属性的setter方法以外,我们还会提供XML中的自定义属性。在开始实现这个自定义View以前,我们就应该思考这个自定义View应该对外提供哪些可配置化的属性?

观察效果图得知,需要提供以下六个属性:

1.欲绘制的文本内容

横幅上是要显示文字内容的,因此需要让用户能够设置文字内容。我们将欲绘制的文本内容保存到bannerText变量中,代码如下:

var bannerText = ""

2.横幅文本颜色

我们将横幅中字体的颜色保存在bannerTextColor变量中,并预置一个默认的文字颜色,代码如下:

/**
* 横幅文字颜色
*/
var bannerTextColor = DEFAULT_BANNER_TEXT_COLOR

companion object {
    private const val DEFAULT_BANNER_TEXT_COLOR = Color.WHITE
}

3.横幅文本大小

我们将横幅中字体的颜色保存在bannerTextSize变量中,并预置一个默认的文字大小,代码如下:

/**
* 横幅文字大小
*/
var bannerTextSize = DEFAULT_BANNER_TEXT_SIZE

companion object {
    // ...
    private val DEFAULT_BANNER_TEXT_SIZE = 12.sp
}

得益于Kotlin的扩展属性功能,我们可以写出如12.sp这样优雅的代码。扩展属性编写在ConvertExtension.kt中:

internal val Float.dp: Int
    get() = TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_DIP,
        this,
        Resources.getSystem().displayMetrics
    ).toInt()

internal val Int.dp: Int
    get() = TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_DIP,
        toFloat(),
        Resources.getSystem().displayMetrics
    ).toInt()

internal val Float.sp: Int
    get() = TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_SP,
        this,
        Resources.getSystem().displayMetrics
    ).toInt()

internal val Int.sp: Int
    get() = TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_SP,
        toFloat(),
        Resources.getSystem().displayMetrics
    ).toInt()

4.横幅的背景颜色

横幅的颜色也是可以让用户动态指定的,但我们需要预设一个默认颜色,我们将它保存到bannerBackgroundColor变量中,代码如下:

/**
 * 横幅背景颜色
 */
var bannerBackgroundColor = DEFAULT_BANNER_BACKGROUND_COLOR

companion object {
    // ...
    private val DEFAULT_BANNER_BACKGROUND_COLOR = Color.parseColor("#FF8080")
}

5.横幅最远点距离左上角的距离

横幅最远点的意思是什么呢?我们通过一张图来解释一下这个「横幅最远点」是什么意思:

image-20200904141659634

这两条红色虚线的长度是一致的,它的长度就是「横幅最远点」距离原点的距离值,这个值会直接影响横幅在View中绘制的位置。同样这个值是可以让用户指定的,我们将它保存到bannerDistanceOriginPointLength变量中,代码如下:

/**
* 最远点距离View(0,0)点距离
*/
var bannerDistanceOriginPointLength = DEFAULT_BANNER_DISTANCE_ORIGIN_POINT_LENGTH

companion object {
    private val DEFAULT_BANNER_DISTANCE_ORIGIN_POINT_LENGTH = 60.dp
}

6.横幅的宽度

通过设置横幅最远点距离,我们能够得到两个点的坐标,剩下两个点怎么确定呢?就要看横幅的宽度了,横幅宽度的定义如下图所示:

image-20200904144148587

那么我们只需用横幅最远点距离 - 横幅宽度,就可以得到剩下两个点的坐标了。有了4个点的坐标,整个横幅的位置也就确定了。我们将横幅的宽度保存在bannerWidth变量中,代码如下:

/**
* 横幅宽度
*/
var bannerWidth = DEFAULT_BANNER_WIDTH

companion object {
    private val DEFAULT_BANNER_WIDTH = 26.dp
}

自此,完整的代码如下:

/**
 * @author HurryYu
 * https://www.hurryyu.com
 * https://github.com/HurryYu
 * 2020-08-31
 */
class CornerLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    var bannerText = ""
        set(value) {
            field = value
            realDrawBannerText = value
        }

    /**
     * 真正绘制的文字,如果bannerText过长,可能会被裁剪
     */
    private var realDrawBannerText = bannerText

    /**
     * 横幅背景颜色
     */
    var bannerBackgroundColor = DEFAULT_BANNER_BACKGROUND_COLOR

    /**
     * 最远点距离View(0,0)点距离
     */
    var bannerDistanceOriginPointLength = DEFAULT_BANNER_DISTANCE_ORIGIN_POINT_LENGTH

    /**
     * 横幅宽度
     */
    var bannerWidth = DEFAULT_BANNER_WIDTH

    /**
     * 横幅文字颜色
     */
    var bannerTextColor = DEFAULT_BANNER_TEXT_COLOR

    /**
     * 横幅文字大小
     */
    var bannerTextSize = DEFAULT_BANNER_TEXT_SIZE

    companion object {
        private val DEFAULT_BANNER_BACKGROUND_COLOR = Color.parseColor("#FF8080")
        private val DEFAULT_BANNER_DISTANCE_ORIGIN_POINT_LENGTH = 60.dp
        private val DEFAULT_BANNER_WIDTH = 26.dp
        private const val DEFAULT_BANNER_TEXT_COLOR = Color.WHITE
        private val DEFAULT_BANNER_TEXT_SIZE = 12.sp
    }
}

四、让用户能够在XML中配置属性

为了让用户使用更加方便,我们还需要提供自定义attrs,在res/values/attrs.xml中编写如下代码:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CornerLayout">
        <attr name="bannerBackgroundColor" format="color" />
        <attr name="bannerDistanceLength" format="dimension" />
        <attr name="bannerWidth" format="dimension" />
        <attr name="bannerTextColor" format="color" />
        <attr name="bannerTextSize" format="dimension" />
        <attr name="bannerText" format="string" />
    </declare-styleable>
</resources>

并且在自定义ViewGroup中获取XML中用户设置的自定义属性值:

init {
    initAttrs(attrs)
}

private fun initAttrs(attrs: AttributeSet?) {
    attrs?.let {
        context.obtainStyledAttributes(it, R.styleable.CornerLayout)
    }?.apply {
        bannerBackgroundColor = getColor(
            R.styleable.CornerLayout_bannerBackgroundColor,
            DEFAULT_BANNER_BACKGROUND_COLOR
        )
        bannerDistanceOriginPointLength = getDimensionPixelSize(
            R.styleable.CornerLayout_bannerDistanceLength,
            DEFAULT_BANNER_DISTANCE_ORIGIN_POINT_LENGTH
        )
        bannerWidth = getDimensionPixelSize(
            R.styleable.CornerLayout_bannerWidth,
            DEFAULT_BANNER_WIDTH
        )
        bannerTextColor = getColor(
            R.styleable.CornerLayout_bannerTextColor,
            DEFAULT_BANNER_TEXT_COLOR
        )
        bannerTextSize = getDimensionPixelSize(
            R.styleable.CornerLayout_bannerTextSize,
            DEFAULT_BANNER_TEXT_SIZE
        )
        bannerText = getString(R.styleable.CornerLayout_bannerText) ?: ""
    }
}

我们在init构造代码块中调用了initAttrs方法执行自定义属性的获取操作。

五、绘制前的准备工作

1.ViewGroup的onDraw默认不会调用

通过代码:setWillNotDraw(false)即可解决。我们在init构造代码块中调用:

init {
    initAttrs(attrs)
    setWillNotDraw(false)
}

2.真的要在onDraw中绘制横幅?

我们知道,ViewGroup的onDraw调用时机是要早于它的子View的onDraw调用时机的,这样一来,如果子View设置了background,或是子View的绘制内容和我们的横幅有重叠,那么我们的横幅是会被覆盖掉的。因此我们不能在ViewGroup的onDraw中绘制横幅。

那有哪个方法是在子View的onDraw完成后才调用的呢?答案是:onDrawForeground

3.得到ViewGroup的宽高

一般情况下,用户会将我们的ViewGroup套在外层,宽高设置为wrap_content,即我们的ViewGroup的大小等于子View的大小,因此我们直接在onSizeChanged中获取ViewGroup的宽高:

private var viewWidth = 0
private var viewHeight = 0

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    viewWidth = w
    viewHeight = h
}

4.准备两只画笔

除了绘制横幅的背景,我们还需要绘制横幅上的文字,因此这里为绘制横幅背景 和 绘制横幅文字 各自准备一只Paint(当然也可以只用一只Paint改变属性复用)。代码如下:

/**
 * 边角横幅画笔
 */
private val bannerPaint = Paint(Paint.ANTI_ALIAS_FLAG)

/**
 * 横幅文字画笔
 */
private val bannerTextPaint = Paint(Paint.ANTI_ALIAS_FLAG)

init {
    initAttrs(attrs)

    bannerPaint.apply {
        color = bannerBackgroundColor
        style = Paint.Style.FILL
    }

    bannerTextPaint.apply {
        color = bannerTextColor
        textSize = bannerTextSize.toFloat()
        style = Paint.Style.FILL
        textAlign = Paint.Align.LEFT
    }

    setWillNotDraw(false)
}

init中对两只画笔进行了各自的配置。到此我们所有的准备工作都做完了。除了绘制外,完整的代码如下:

package com.hurryyu.cornerlayout

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.util.AttributeSet
import android.widget.FrameLayout
import kotlin.math.pow
import kotlin.math.sqrt

/**
 * @author HurryYu
 * https://www.hurryyu.com
 * https://github.com/HurryYu
 * 2020-08-31
 */
class CornerLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    /**
     * 边角横幅画笔
     */
    private val bannerPaint = Paint(Paint.ANTI_ALIAS_FLAG)

    /**
     * 横幅文字画笔
     */
    private val bannerTextPaint = Paint(Paint.ANTI_ALIAS_FLAG)

    /**
     * 横幅Path
     */
    private val bannerPath: Path = Path()

    private var viewWidth = 0

    private var viewHeight = 0

    var bannerText = ""
        set(value) {
            field = value
            realDrawBannerText = value
        }

    /**
     * 真正绘制的文字,如果bannerText过长,可能会被裁剪
     */
    private var realDrawBannerText = bannerText

    /**
     * 横幅背景颜色
     */
    var bannerBackgroundColor = DEFAULT_BANNER_BACKGROUND_COLOR

    /**
     * 最远点距离View(0,0)点距离
     */
    var bannerDistanceOriginPointLength = DEFAULT_BANNER_DISTANCE_ORIGIN_POINT_LENGTH

    /**
     * 横幅宽度
     */
    var bannerWidth = DEFAULT_BANNER_WIDTH

    /**
     * 横幅文字颜色
     */
    var bannerTextColor = DEFAULT_BANNER_TEXT_COLOR

    /**
     * 横幅文字大小
     */
    var bannerTextSize = DEFAULT_BANNER_TEXT_SIZE

    init {
        initAttrs(attrs)

        bannerPaint.apply {
            color = bannerBackgroundColor
            style = Paint.Style.FILL
        }

        bannerTextPaint.apply {
            color = bannerTextColor
            textSize = bannerTextSize.toFloat()
            style = Paint.Style.FILL
            textAlign = Paint.Align.LEFT
        }

        setWillNotDraw(false)
    }

    private fun initAttrs(attrs: AttributeSet?) {
        attrs?.let {
            context.obtainStyledAttributes(it, R.styleable.CornerLayout)
        }?.apply {
            bannerBackgroundColor = getColor(
                R.styleable.CornerLayout_bannerBackgroundColor,
                DEFAULT_BANNER_BACKGROUND_COLOR
            )
            bannerDistanceOriginPointLength = getDimensionPixelSize(
                R.styleable.CornerLayout_bannerDistanceLength,
                DEFAULT_BANNER_DISTANCE_ORIGIN_POINT_LENGTH
            )
            bannerWidth = getDimensionPixelSize(
                R.styleable.CornerLayout_bannerWidth,
                DEFAULT_BANNER_WIDTH
            )
            bannerTextColor = getColor(
                R.styleable.CornerLayout_bannerTextColor,
                DEFAULT_BANNER_TEXT_COLOR
            )
            bannerTextSize = getDimensionPixelSize(
                R.styleable.CornerLayout_bannerTextSize,
                DEFAULT_BANNER_TEXT_SIZE
            )
            bannerText = getString(R.styleable.CornerLayout_bannerText) ?: ""
        }
    }

    override fun onDrawForeground(canvas: Canvas) {
        super.onDrawForeground(canvas)
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        viewWidth = w
        viewHeight = h
    }

    companion object {
        private val DEFAULT_BANNER_BACKGROUND_COLOR = Color.parseColor("#FF8080")
        private val DEFAULT_BANNER_DISTANCE_ORIGIN_POINT_LENGTH = 60.dp
        private val DEFAULT_BANNER_WIDTH = 26.dp
        private const val DEFAULT_BANNER_TEXT_COLOR = Color.WHITE
        private val DEFAULT_BANNER_TEXT_SIZE = 12.sp
    }
}

六、绘制横幅

/**
 * 绘制横幅
 */
private fun drawBanner(canvas: Canvas) {
    val x1Point = arrayOf(bannerDistanceOriginPointLength - bannerWidth.toFloat(), 0F)
    val x2Point = arrayOf(bannerDistanceOriginPointLength.toFloat(), 0F)
    val y1Point = arrayOf(0F, bannerDistanceOriginPointLength - bannerWidth.toFloat())
    val y2Point = arrayOf(0F, bannerDistanceOriginPointLength.toFloat())

    bannerPath.apply {
        reset()
        moveTo(y1Point[0], y1Point[1])
        lineTo(x1Point[0], x1Point[1])
        lineTo(x2Point[0], x2Point[1])
        lineTo(y2Point[0], y2Point[1])
    }
    canvas.drawPath(bannerPath, bannerPaint)
}
  • x1Point表示横坐标上距离原点最近的那个点,也就是用「横幅最远点距离」减去「横幅宽度」。
  • x2Point表示横坐标上「横幅最远点」的坐标。

剩下的y1Pointy2Point同理了,只不过是纵坐标。接着就是使用Path绘制了。这里需要注意绘制的顺序,因为我们等会儿绘制文字的时候,会使用drawTextOnPath来绘制。

Path的绘制顺序如图:

image-20200904154313227

七、绘制横幅文字

可绘制文字的区域是有限的,但是用户可能传入一个很长的文本内容进行绘制,这样绘制出来的效果肯定是不好的。因此在绘制文字之前,我们需要判断可绘制文本的长度是否大于或等于欲绘制文本的长度,如果欲绘制文本的长度大于了可绘制文本的长度,则需要对用户欲绘制的文本内容进行裁剪,直到绘制内容长度小于或等于可绘制文本长度。实现的代码如下:

/**
 * 绘制横幅上的文字
 */
private fun drawText(canvas: Canvas) {
    // 测量欲绘制文字宽度
    val bannerTextWidth = bannerTextPaint.measureText(realDrawBannerText)
    // 计算banner最短边长度
    val bannerShortestLength =
        (sqrt(
            2 * (bannerDistanceOriginPointLength - bannerWidth).toDouble().pow(2)
        )).toFloat()
    if (bannerTextWidth > bannerShortestLength) {
        // 如果最短边长度小于欲绘制文字长度,则对欲绘制文字剪裁,直到欲绘制文字比最短边长度小方可绘制文字
        realDrawBannerText = realDrawBannerText.substring(0, realDrawBannerText.length - 1)
        drawText(canvas)
        return
    }
}

注意这里的bannerShortestLength是指横幅最短边的长度,如下图:

image-20200904155955630

现在我们开始绘制文字,使用:canvas.drawTextOnPath(realDrawBannerText, bannerPath, 0F, 0F, bannerTextPaint),如果绘制的文本内容是:HurryYu,那么绘制出来可能是这个样子:

image-20200904162716977

很显然绘制文本的基线是横幅的最短边。

现在我们就要来看看drawTextOnPath的第3个和第4个参数的作用了。

public void drawTextOnPath(@NonNull String text, @NonNull Path path, float hOffset, float vOffset, @NonNull Paint paint)
  • hOffset:水平的偏移量
  • vOffset:垂直的偏移量

怎么计算呢?hOffset是最简单的,只需要算出横幅最短边的长度的一半,再减去绘制文本宽度的一半即可让绘制的文本水平居中了:

val hOffset = bannerShortestLength / 2 - bannerTextWidth / 2

vOffset就要复杂一点了,我们首先需要计算出横幅的高度,注意,这个高度不等于横幅的宽度(bannerWidth)。

其实就是计算一个等腰梯形的高度,底边和顶边的长度是已知的,腰长也是已知的,算高度就简单了:

// 计算banner最长边长度
val bannerLongestLength =
    (sqrt(2 * (bannerDistanceOriginPointLength).toDouble().pow(2))).toFloat()
// 单个直角边长度
val oneOfTheRightAngleLength = (bannerLongestLength - bannerShortestLength) / 2
// 计算banner的高度
val bannerHeight =
    sqrt(bannerWidth.toDouble().pow(2) - oneOfTheRightAngleLength.pow(2)).toFloat()

高度有了,那么vOffset就比较清晰了,它应该为bannerHeight / 2,也就是说,我们将绘制文本的基线移动到了横幅的中间。那么现在绘制出来的文字大概是这个样子:

image-20200904164008146

中间那条骚紫色的虚线就是我们绘制文本内容的基线了。你会发现,文字还是没有居中,我们需要对基线做一个偏移,代码如下:

val fontMetrics = bannerTextPaint.fontMetrics
// 计算baseLine偏移量
val baseLineOffset = (fontMetrics.top + fontMetrics.bottom) / 2
val vOffset = bannerHeight / 2 - baseLineOffset

现在的vOffset才是真正的vOffset,现在我们再使用算出来的hOffsetvOffset来绘制文字:

canvas.drawTextOnPath(realDrawBannerText, bannerPath, hOffset, vOffset, bannerTextPaint)

完美了。绘制横幅文字的完整代码如下:

/**
 * 绘制横幅上的文字
 */
private fun drawText(canvas: Canvas) {
    // 测量欲绘制文字宽度
    val bannerTextWidth = bannerTextPaint.measureText(realDrawBannerText)
    // 计算banner最短边长度
    val bannerShortestLength =
        (sqrt(
            2 * (bannerDistanceOriginPointLength - bannerWidth).toDouble().pow(2)
        )).toFloat()
    if (bannerTextWidth > bannerShortestLength) {
        // 如果最短边长度小于欲绘制文字长度,则对欲绘制文字剪裁,直到欲绘制文字比最短边长度小方可绘制文字
        realDrawBannerText = realDrawBannerText.substring(0, realDrawBannerText.length - 1)
        drawText(canvas)
        return
    }
    // a^2 + b^2 = c^2
    val hOffset = bannerShortestLength / 2 - bannerTextWidth / 2
    // 计算banner最长边长度
    val bannerLongestLength =
        (sqrt(2 * (bannerDistanceOriginPointLength).toDouble().pow(2))).toFloat()
    // 单个直角边长度
    val oneOfTheRightAngleLength = (bannerLongestLength - bannerShortestLength) / 2
    // 计算banner的高度
    val bannerHeight =
        sqrt(bannerWidth.toDouble().pow(2) - oneOfTheRightAngleLength.pow(2)).toFloat()

    val fontMetrics = bannerTextPaint.fontMetrics
    // 计算baseLine偏移量
    val baseLineOffset = (fontMetrics.top + fontMetrics.bottom) / 2
    val vOffset = bannerHeight / 2 - baseLineOffset

    canvas.drawTextOnPath(realDrawBannerText, bannerPath, hOffset, vOffset, bannerTextPaint)
}

八、总结

自定义ViewGroup就完成了,用起来那就是相当爽了:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    tools:context=".MainActivity">

    <com.hurryyu.cornerlayout.CornerLayout
        android:id="@+id/cornerLayout"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:bannerBackgroundColor="@color/colorPrimary"
        app:bannerDistanceLength="75dp"
        app:bannerText="限时6折"
        app:bannerTextColor="#FFFFFF"
        app:bannerTextSize="14sp"
        app:bannerWidth="34dp">

        <TextView
            android:layout_width="180dp"
            android:layout_height="130dp"
            android:background="@drawable/shape_book_card"
            android:gravity="center"
            android:text="安徒生童话"
            android:textColor="#FFFFFF"
            android:textSize="22sp"
            android:textStyle="bold" />
    </com.hurryyu.cornerlayout.CornerLayout>

</LinearLayout>

如果对您有帮助,欢迎stat。Github:传送门



Android技术      自定义ViewGroup Path

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!