参考文档: Android知识总结——Path常用方法解析 - 简书
Android ImageView圆角几种方案实现!
目录
效果
代码
难点
小知识
小小总结
话不多说, 先上效果, 再贴代码, 需要的老哥直接拿走
卖家秀(目标效果)
买家秀(实现效果, 放大了一点, 稍微有点糊, 见谅~)
package com.pb.test.compose.ui.danmakuvote
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.orhanobut.logger.Logger
import kotlin.math.tan
/**
*
* @author : YingYing Zhang
* @e-mail : [email protected]
* @time : 2022-12-09
* @desc :
* 画图规则: 从左上角顺时针画~
*
*/
// 弹幕投票 view total width
private val DANMAKU_VOTE_WIDTH = 72.dp
// 弹幕投票 view total height
private val DANMAKU_VOTE_HEIGHT = 72.dp
// 弹幕投票红蓝 view height
private val DANMAKU_VOTE_TOP_HEIGHT = 44.dp
// 弹幕投票计时 view height
private val DANMAKU_VOTE_BOTTOM_HEIGHT = 28.dp
// 内部 view 边距
private val PADDING_INNER_VIEW = 2.dp
// 红蓝间隙宽度
private val GAP_DANMAKU_VOTE_WIDTH = 2.dp
// 梯形右下角角度
private const val DEGREE_LADDER = 85.0
// 进度文案 size
private val RESULT_TEXT_SIZE = 18.sp
// 定时器文案 size
private val TIMER_TEXT_SIZE = 16.sp
// 圆角
private val ROUND_RADIUS = 10.dp
// 最小比例
private const val RATE_MIN = 0.2F
// 最大比例
private const val RATE_MAX = 0.8F
// test data
private const val LEFT_VOTES = 20
private const val RIGHT_VOTES = 20
@Preview(name = "弹幕投票挂件")
@Composable
fun DanmakuVoteView(@PreviewParameter(LiveDanmakuVoteProvider::class) vote: LiveDanmakuVote) {
Column(
modifier = Modifier
.width(DANMAKU_VOTE_WIDTH)
.height(DANMAKU_VOTE_HEIGHT)
.background(color = Color(0x66000000), shape = RoundedCornerShape(ROUND_RADIUS))
) {
Box(modifier = Modifier
.width(DANMAKU_VOTE_WIDTH)
.height(DANMAKU_VOTE_TOP_HEIGHT)) {
Row(
modifier = Modifier
.width(DANMAKU_VOTE_WIDTH)
.wrapContentHeight()
) {
Box {
// 蓝方进度
LiveDanmakuVoteLeftView()
// 蓝方渐变色
LeftInnerView()
}
// gap view
Spacer(modifier = Modifier.width(GAP_DANMAKU_VOTE_WIDTH))
Box {
// 红方进度
LiveDanmakuVoteRightView()
// 红方渐变色
RightInnerView()
}
}
// 当前进度或结果展示
Box(modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center) {
Text(text = "蓝优先",
fontSize = RESULT_TEXT_SIZE,
color = Color.White,
fontWeight = FontWeight.Bold)
}
}
// 倒计时
LiveDanmakuVoteTimer()
}
}
@Preview(name = "蓝方")
@Composable
fun LiveDanmakuVoteLeftView() {
// 获取蓝方宽度
val leftWidth = getLeftWidth(LEFT_VOTES, RIGHT_VOTES)
val delta = deltaTopBottom()
Box {
Canvas(modifier = Modifier
.height(DANMAKU_VOTE_TOP_HEIGHT)
.width(leftWidth),
onDraw = {
// 圆角 path
val roundPath = Path().apply {
addRoundRect(
RoundRect(
left = 0F,
top = 0F,
right = leftWidth.toPx(),
bottom = DANMAKU_VOTE_TOP_HEIGHT.toPx(),
topLeftCornerRadius = CornerRadius(ROUND_RADIUS.toPx(), ROUND_RADIUS.toPx()),
bottomLeftCornerRadius = CornerRadius(ROUND_RADIUS.toPx(), ROUND_RADIUS.toPx())
)
)
}
// 梯形 path
val ladderPath = Path().apply {
moveTo(0F, 0F)
lineTo(0F, DANMAKU_VOTE_TOP_HEIGHT.toPx())
lineTo((leftWidth).toPx(), DANMAKU_VOTE_TOP_HEIGHT.toPx())
lineTo((leftWidth - delta).toPx(), 0F)
lineTo(0F, 0F)
}
// 取 path 交集
roundPath.op(roundPath, ladderPath, PathOperation.Intersect)
drawPath(path = roundPath, color = Color(0xFF008AC5))
})
}
}
@Preview(name = "蓝方渐变色")
@Composable
fun LeftInnerView() {
// 获取蓝方宽度
val leftWidth = getLeftWidth(LEFT_VOTES, RIGHT_VOTES)
val delta = deltaInnerTopBottom()
Box(modifier = Modifier
.height(DANMAKU_VOTE_TOP_HEIGHT)
.width(leftWidth),
contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier
.height(DANMAKU_VOTE_TOP_HEIGHT - PADDING_INNER_VIEW.times(2))
.width(leftWidth - PADDING_INNER_VIEW.times(2)),
onDraw = {
// 圆角 path
val roundPath = Path().apply {
addRoundRect(
RoundRect(
left = 0F,
top = 0F,
right = (leftWidth - PADDING_INNER_VIEW.times(2)).toPx(),
bottom = (DANMAKU_VOTE_TOP_HEIGHT - PADDING_INNER_VIEW.times(2)).toPx(),
topLeftCornerRadius = CornerRadius(ROUND_RADIUS.toPx(), ROUND_RADIUS.toPx()),
bottomLeftCornerRadius = CornerRadius(ROUND_RADIUS.toPx(), ROUND_RADIUS.toPx())
)
)
}
// 梯形 path
val ladderPath = Path().apply {
moveTo(0F, 0F)
lineTo(0F, (DANMAKU_VOTE_TOP_HEIGHT - PADDING_INNER_VIEW.times(2)).toPx())
lineTo(
(leftWidth - PADDING_INNER_VIEW.times(2)).toPx(),
(DANMAKU_VOTE_TOP_HEIGHT - PADDING_INNER_VIEW.times(2)).toPx()
)
lineTo((leftWidth - delta - PADDING_INNER_VIEW.times(2)).toPx(), 0F)
lineTo(0F, 0F)
}
// 取 path 交集
roundPath.op(roundPath, ladderPath, PathOperation.Intersect)
clipPath(path = roundPath) {
drawRect(brush = Brush.verticalGradient(
colors = listOf(
Color(0x66FFFFFF),
Color(0x0FFFFFFF),
Color(0x00FFFFFF)
)
))
}
})
}
}
/**
* 右边红色区域, 独立 view, left 从0开始算...
*/
@Preview(name = "红方")
@Composable
fun LiveDanmakuVoteRightView() {
// 获取红方宽度
val rightWidth = getRightWidth(LEFT_VOTES, RIGHT_VOTES)
val delta = deltaTopBottom()
Box(
modifier = Modifier
.width(rightWidth)
.height(DANMAKU_VOTE_TOP_HEIGHT)
) {
Canvas(modifier = Modifier
.height(DANMAKU_VOTE_TOP_HEIGHT)
.width(rightWidth),
onDraw = {
val left = 0F
val top = 0F
val right = rightWidth.toPx()
val bottom = DANMAKU_VOTE_TOP_HEIGHT.toPx()
// 圆角 path
val roundPath = Path().apply {
addRoundRect(
RoundRect(
left = left,
top = top,
right = right,
bottom = bottom,
topRightCornerRadius = CornerRadius(ROUND_RADIUS.toPx(), ROUND_RADIUS.toPx()),
bottomRightCornerRadius = CornerRadius(ROUND_RADIUS.toPx(), ROUND_RADIUS.toPx())
)
)
}
// 梯形 path
val ladderPath = Path().apply {
moveTo(left, 0F)
lineTo(right, 0F)
lineTo(right, bottom)
lineTo(left + delta.toPx(), bottom)
lineTo(left, 0F)
}
// 取 path 交集
roundPath.op(roundPath, ladderPath, PathOperation.Intersect)
drawPath(path = roundPath, color = Color(0xFFD03171))
})
}
}
@Preview(name = "红方渐变色")
@Composable
fun RightInnerView() {
// 获取红方宽度
val rightWidth = getRightWidth(LEFT_VOTES, RIGHT_VOTES)
val delta = deltaInnerTopBottom()
Box(modifier = Modifier
.height(DANMAKU_VOTE_TOP_HEIGHT)
.width(rightWidth),
contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier
.height(DANMAKU_VOTE_TOP_HEIGHT - PADDING_INNER_VIEW.times(2))
.width(rightWidth - PADDING_INNER_VIEW.times(2)),
onDraw = {
// 圆角 path
val left = 0F
val top = 0F
val right = (rightWidth - PADDING_INNER_VIEW.times(2)).toPx()
val bottom = (DANMAKU_VOTE_TOP_HEIGHT - PADDING_INNER_VIEW.times(2)).toPx()
val roundPath = Path().apply {
addRoundRect(
RoundRect(
left = left,
top = top,
right = right,
bottom = bottom,
topRightCornerRadius = CornerRadius(ROUND_RADIUS.toPx(), ROUND_RADIUS.toPx()),
bottomRightCornerRadius = CornerRadius(ROUND_RADIUS.toPx(), ROUND_RADIUS.toPx())
)
)
}
Logger.d("Simon.Debug right inner view, left = $left, top = $top, right = $right, bottom = $bottom")
// 梯形 path
val ladderPath = Path().apply {
moveTo(left, top)
lineTo(right, left)
lineTo(right, bottom)
lineTo(left.plus(delta.toPx()), bottom)
close()
}
// 取 path 交集
roundPath.op(roundPath, ladderPath, PathOperation.Intersect)
clipPath(path = roundPath) {
drawRect(brush = Brush.verticalGradient(
colors = listOf(
Color(0x66FFFFFF),
Color(0x0FFFFFFF),
Color(0x00FFFFFF)
)
))
}
})
}
}
@Preview(name = "倒计时")
@Composable
fun LiveDanmakuVoteTimer() {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.height(DANMAKU_VOTE_BOTTOM_HEIGHT)
.fillMaxWidth()
.background(
color = Color.Transparent,
shape = RoundedCornerShape(bottomStart = ROUND_RADIUS, bottomEnd = ROUND_RADIUS)
)
) {
Text(
modifier = Modifier
.wrapContentWidth()
.wrapContentHeight(),
text = "04:59",
color = Color.White,
fontSize = TIMER_TEXT_SIZE
)
}
}
@Preview(name = "渐变色")
@Composable
fun TestGradient() {
Box(modifier = Modifier
.height(100.dp)
.width(100.dp)
.background(color = Color.Red),
contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier
.width(80.dp)
.height(80.dp)
.background(color = Color.Yellow), onDraw = {
val roundPath = Path().apply {
addRoundRect(RoundRect(
rect = Rect(0F, 0F, 50.dp.toPx(), 50.dp.toPx()),
topLeft = CornerRadius(ROUND_RADIUS.toPx(), ROUND_RADIUS.toPx())
))
}
val ladderPath = Path().apply {
moveTo(0F, 0F)
lineTo(50.dp.toPx(), 0F)
lineTo(0F, 50.dp.toPx())
close()
}
roundPath.op(roundPath, ladderPath, PathOperation.Intersect)
clipPath(path = roundPath) {
drawRoundRect(brush = Brush.verticalGradient(
colors = listOf(
Color.Black,
Color.Blue
)
))
}
})
}
}
private fun getLeftWidth(leftVotes: Int, rightVotes: Int): Dp {
val curRate = leftVotes.toFloat() / (leftVotes + rightVotes)
val leftRate = when {
curRate < RATE_MIN -> RATE_MIN
curRate > RATE_MAX -> RATE_MAX
else -> curRate
}
val result = calculateWidth(leftRate)
Logger.d("Simon.Debug getLeftWidth curRate = $curRate, leftRate = $leftRate, result = $result")
return result
}
private fun getRightWidth(leftVotes: Int, rightVotes: Int): Dp {
val curRate = rightVotes.toFloat() / (leftVotes + rightVotes)
val rightRate = when {
curRate < RATE_MIN -> RATE_MIN
curRate > RATE_MAX -> RATE_MAX
else -> curRate
}
val result = calculateWidth(rightRate)
Logger.d("Simon.Debug getRightWidth curRate = $curRate, rightRate = $rightRate, result = $result")
return result
}
// 根据票数占比获取当前宽度
private fun calculateWidth(rate: Float): Dp {
return DANMAKU_VOTE_WIDTH.minus(GAP_DANMAKU_VOTE_WIDTH).times(rate)
}
// 根据角度获取上下高度差
@Composable
private fun deltaTopBottom(): Dp {
var deltaDp = 10.dp
with(LocalDensity.current) {
val delta = DANMAKU_VOTE_TOP_HEIGHT.toPx().div(tan(Math.toRadians(DEGREE_LADDER)))
deltaDp = delta.toInt().toDp()
Logger.d("Simon.Debug DeltaTopBottom deltaDp = $deltaDp, delta = $delta")
}
Logger.d("Simon.Debug DeltaTopBottom return deltaDp = $deltaDp")
return deltaDp
}
// 根据角度获取内部 view 上下高度差
@Composable
private fun deltaInnerTopBottom(): Dp {
var deltaDp = 8.dp
with(LocalDensity.current) {
val delta = (DANMAKU_VOTE_TOP_HEIGHT - PADDING_INNER_VIEW.times(2)).toPx().div(tan(Math.toRadians(DEGREE_LADDER)))
deltaDp = delta.toInt().toDp()
Logger.d("Simon.Debug deltaInnerTopBottom deltaDp = $deltaDp, delta = $delta")
}
Logger.d("Simon.Debug deltaInnerTopBottom return deltaDp = $deltaDp")
return deltaDp
}
其实虽然说是 compose 自定义 view, 实际上和传统的自定义 view 没有什么区别, 都是通过 canvas draw 自己想要的图案.
非要说有难点的话有两个:
1.绘制圆角梯形
2.绘制圆角渐变色梯形
问题1: 绘制圆角梯形
可能刚看到圆角梯形的同学有点懵, 不要怕, 我们一步一步来, 首先思考怎么拿到这个圆角梯形,
1.绘制一个左边是圆角的矩形的 path
2.绘制一个宽高相同的梯形 path
3.两者取交集, 就变成了圆角梯形, 就是这么简单...
问题2: 绘制圆角渐变色梯形
和上面逻辑类似, 首先我们得到了一个圆角梯形的 path,
然后使用 clipPath 方法, 把这个圆角梯形的 path 的画布剪出来,
也就是说我们在这个 path 上绘制的图案最大也就是圆角梯形 path 的范围啦
然后再在 clipPath 的 DrawScope 中绘制渐变色就完成啦~
什么? 你还不知道怎么拿到 compose 生成的 view?
请看下面的代码~
@Composable
fun getDanmakuVoteView(context: Context) = ComposeView(context).apply {
setContent {
DanmakuVoteView(vote = LiveDanmakuVote(10, 10))
}
}
代码都在上面, 注释也写的很清楚, 仅作为第一次自定义 compose view 的一个记录.
后续再画图, 会按照一定的规则, 比如画矩形, 一定是从左上角顶点开始, 顺时针一个点一个点连接.
不然 重新设置坐标顺序点总是很懵...调试起来找不到自己在哪里...