有时候,UI可能会设计一个效果,需要我们在View的左上角加上一个横幅,并在横幅上添加文字显示,例如下面这张图的效果:
紫色部分就是我们所说的“横幅”。这个效果如何实现呢?两种方案:
- 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.横幅最远点距离左上角的距离
横幅最远点的意思是什么呢?我们通过一张图来解释一下这个「横幅最远点」是什么意思:
这两条红色虚线的长度是一致的,它的长度就是「横幅最远点」距离原点的距离值,这个值会直接影响横幅在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.横幅的宽度
通过设置横幅最远点距离,我们能够得到两个点的坐标,剩下两个点怎么确定呢?就要看横幅的宽度了,横幅宽度的定义如下图所示:
那么我们只需用横幅最远点距离 - 横幅宽度
,就可以得到剩下两个点的坐标了。有了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
表示横坐标上「横幅最远点」的坐标。
剩下的y1Point
和y2Point
同理了,只不过是纵坐标。接着就是使用Path
绘制了。这里需要注意绘制的顺序,因为我们等会儿绘制文字的时候,会使用drawTextOnPath
来绘制。
Path的绘制顺序如图:
七、绘制横幅文字
可绘制文字的区域是有限的,但是用户可能传入一个很长的文本内容进行绘制,这样绘制出来的效果肯定是不好的。因此在绘制文字之前,我们需要判断可绘制文本的长度是否大于或等于欲绘制文本的长度,如果欲绘制文本的长度大于了可绘制文本的长度,则需要对用户欲绘制的文本内容进行裁剪,直到绘制内容长度小于或等于可绘制文本长度。实现的代码如下:
/**
* 绘制横幅上的文字
*/
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
是指横幅最短边的长度,如下图:
现在我们开始绘制文字,使用:canvas.drawTextOnPath(realDrawBannerText, bannerPath, 0F, 0F, bannerTextPaint)
,如果绘制的文本内容是:HurryYu
,那么绘制出来可能是这个样子:
很显然绘制文本的基线是横幅的最短边。
现在我们就要来看看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
,也就是说,我们将绘制文本的基线移动到了横幅的中间。那么现在绘制出来的文字大概是这个样子:
中间那条骚紫色的虚线就是我们绘制文本内容的基线了。你会发现,文字还是没有居中,我们需要对基线做一个偏移,代码如下:
val fontMetrics = bannerTextPaint.fontMetrics
// 计算baseLine偏移量
val baseLineOffset = (fontMetrics.top + fontMetrics.bottom) / 2
val vOffset = bannerHeight / 2 - baseLineOffset
现在的vOffset
才是真正的vOffset
,现在我们再使用算出来的hOffset
和vOffset
来绘制文字:
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:传送门