Android Compose 自定义 View 实践

参考文档: Android知识总结——Path常用方法解析 - 简书

Android ImageView圆角几种方案实现!

目录

效果

代码

难点

小知识

小小总结


话不多说, 先上效果, 再贴代码, 需要的老哥直接拿走

效果

卖家秀(目标效果)

Android Compose 自定义 View 实践_第1张图片

买家秀(实现效果, 放大了一点, 稍微有点糊, 见谅~)

Android Compose 自定义 View 实践_第2张图片

代码

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.绘制圆角梯形

Android Compose 自定义 View 实践_第3张图片

2.绘制圆角渐变色梯形

Android Compose 自定义 View 实践_第4张图片

问题1: 绘制圆角梯形

可能刚看到圆角梯形的同学有点懵, 不要怕, 我们一步一步来, 首先思考怎么拿到这个圆角梯形, 

1.绘制一个左边是圆角的矩形的 path

Android Compose 自定义 View 实践_第5张图片

2.绘制一个宽高相同的梯形 path

Android Compose 自定义 View 实践_第6张图片

3.两者取交集, 就变成了圆角梯形, 就是这么简单...

Android Compose 自定义 View 实践_第7张图片

问题2: 绘制圆角渐变色梯形

和上面逻辑类似, 首先我们得到了一个圆角梯形的 path, 

然后使用 clipPath 方法, 把这个圆角梯形的 path 的画布剪出来, 

也就是说我们在这个 path 上绘制的图案最大也就是圆角梯形 path 的范围啦

然后再在 clipPath 的 DrawScope 中绘制渐变色就完成啦~ 

Android Compose 自定义 View 实践_第8张图片

小知识

什么? 你还不知道怎么拿到 compose 生成的 view?

请看下面的代码~

@Composable
fun getDanmakuVoteView(context: Context) = ComposeView(context).apply {
    setContent {
        DanmakuVoteView(vote = LiveDanmakuVote(10, 10))
    }
}

小小总结

代码都在上面, 注释也写的很清楚, 仅作为第一次自定义 compose view 的一个记录.

后续再画图, 会按照一定的规则, 比如画矩形, 一定是从左上角顶点开始, 顺时针一个点一个点连接.

不然 重新设置坐标顺序点总是很懵...调试起来找不到自己在哪里...

你可能感兴趣的:(compose,android,android,studio,动画)