android横幅轮播,Android自定义View实战-边角横幅

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

53989dc3b6cd

image-20200902154545916.png

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

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.横幅最远点距离左上角的距离

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

53989dc3b6cd

image-20200904141659634.png

这两条红色虚线的长度是一致的,它的长度就是「横幅最远点」距离原点的距离值,这个值会直接影响横幅在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.横幅的宽度

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

53989dc3b6cd

image-20200904144148587.png

那么我们只需用横幅最远点距离 - 横幅宽度,就可以得到剩下两个点的坐标了。有了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中编写如下代码:

并且在自定义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的绘制顺序如图:

53989dc3b6cd

image-20200904154313227.png

七、绘制横幅文字

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

/**

* 绘制横幅上的文字

*/

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是指横幅最短边的长度,如下图:

53989dc3b6cd

image-20200904155955630.png

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

53989dc3b6cd

image-20200904162716977.png

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

现在我们就要来看看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,也就是说,我们将绘制文本的基线移动到了横幅的中间。那么现在绘制出来的文字大概是这个样子:

53989dc3b6cd

image-20200904164008146.png

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

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就完成了,用起来那就是相当爽了:

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">

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">

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" />

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值