Compose 自定义转盘

compose bom 2025.04.00版本

实现效果

  1. 自动旋转转盘
  2. 手滑旋转转盘

基础组件

  • Canvas组件

    1. drawCircle 绘制圆形
    2. drawOval 绘制椭圆
    3. drawArc 绘制扇形
    4. drawText 绘制文字
    5. drawPath 绘制路径
  • 状态管理 MutableState

  • 动画 Animatable

  • 副作用 LaunchedEffect

  • 手势处理 pointerInput {detectDragGestures}

MyWheelView 圆盘组件

根据segments均等分圆盘,rotationAngle来实现圆盘旋转效果

ini 复制代码
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
@Preview
fun MyWheelView(
    modifier: Modifier = Modifier,
    segments: List<WheelSegment>,
    selectedIndex: Int = -1,
    rotationAngle: Float = 0f,
) {
    val textMeasurer = rememberTextMeasurer()
    //每个扇形的
    val segmentAngle = 360f / segments.size
    BoxWithConstraints(modifier) {
        val canvasSize = min(constraints.maxWidth, constraints.maxHeight).toFloat()
        Canvas(modifier = Modifier.size(canvasSize.dp)) {
            val outSize = 10.dp.toPx()
            //绘制背景
            drawCircle(
                color = Color(0xFFE54615),
                radius = size.width / 2f,
            )
            //内边距
            inset(outSize / 2f) {
                val size = size.width
                //绘制外边框
                drawOval(
                    color = Color.White,
                    style = Stroke(
                        width = 2.dp.toPx(),
                    ),
                )
                segments.forEachIndexed { index, segment ->
                    //rotationAngle 旋转角度
                    val startAngle = rotationAngle + index * segmentAngle
                    val sweepAngle = segmentAngle
                    // 根据是否选中决定颜色
                    val segmentColor = if (index == selectedIndex) {
                        segment.color
                    } else {
                        segment.color.copy(alpha = 0.6f)
                    }
                    //绘制扇形
                    //相对于[startAngle]顺时针绘制的以度为单位的弧的大小
                    drawArc(
                        color = segmentColor,
                        sweepAngle = sweepAngle.toFloat(),
                        startAngle = startAngle,
                        useCenter = true
                    )
                    //绘制扇形边框
                    drawArc(
                        color = Color.White,
                        sweepAngle = sweepAngle.toFloat(),
                        startAngle = startAngle,
                        style = Stroke(
                            width = 2.dp.roundToPx().toFloat(),
                        ),
                        useCenter = true
                    )

                    // 绘制扇形上的文字
                    val middleAngle = startAngle + sweepAngle / 2f
                    val radius = size / 2f * 0.6f
                    val x = size / 2f + cos(Math.toRadians(middleAngle.toDouble())) * radius
                    val y = size / 2f + sin(Math.toRadians(middleAngle.toDouble())) * radius
                    val textLayoutResult = textMeasurer.measure(
                        //可配置样式 or text:String = ""
                        text = AnnotatedString(
                            text = segment.text,
                            spanStyle = SpanStyle(
                                fontSize = 10.sp,
                                color = Color.Black
                            )
                        ),
                        style = TextStyle(
                            fontSize = 10.sp,
                            color = Color.Black
                        )
                    )
                    //compose canvas绘制
                    drawText(
                        color = Color.Black,
                        textLayoutResult = textLayoutResult,
                        topLeft = Offset(
                            x = x.toFloat() - textLayoutResult.size.width / 2f,
                            y = y.toFloat() - textLayoutResult.size.height / 2f
                        ),
                    )
                    //绘制顶部三角指针
                    val halfSize = size / 2f
                    val pointWidth = 20.dp.roundToPx()
                    val path = Path().apply {
                        moveTo(halfSize - pointWidth / 2f, 0f)
                        lineTo(halfSize + pointWidth / 2f, 0f)
                        lineTo(halfSize, (pointWidth * sin(Math.toRadians(60.0))).toFloat())
                        close()
                    }
                    drawPath(
                        path,
                        color = Color.Black,
                    )
                    //绘制中心圆
                    drawCircle(
                        color = Color.Gray,
                        radius = outSize,
                    )
                    drawCircle(
                        color = Color.White,
                        radius = 2 * outSize / 3f,
                    )
                }
            }
        }
    }
}

data class WheelSegment(
    val text: String,
    val color: Color,
    val value: String,
)


/**
 * 指定index,确定最终角度
 */
fun selectSegment(count: Int, selectedIndex: Int): Float {
    if (count <= selectedIndex) return 0f
    val segmentAngle = 360f / segments.size
    val targetAngle =
        270f - (selectedIndex * segmentAngle + (10 until segmentAngle.toInt() - 10).random())
    return targetAngle % 360f
}

// 根据当前角度获取选中索引
fun getSelectedIndex(segmentCount: Int, rotationAngle: Float): Int {
    val segmentAngle = 360 / segmentCount
    val normalizedAngle = (360f - (rotationAngle % 360f) + 270f) % 360f
    return (normalizedAngle / segmentAngle).toInt().coerceIn(0, segmentCount - 1)
}

RotatableWheelView 圆盘动画组件

重点是根据snapTo()旋转到固定selectedIndex指定的角度

ini 复制代码
@Composable
fun RotatableWheelView(
    modifier: Modifier = Modifier,
    segments: List<WheelSegment>,
    selectedIndex: Int = -1,
    isSpinning: Boolean = false,
    onSpinEnd: () -> Unit = {}
) {
    var rotationAngle by remember { mutableStateOf(0f) }
    val animatable = remember { Animatable(0f) }

    LaunchedEffect(isSpinning) {
        if (isSpinning) {
            rotationAngle = selectSegment(segments.size, selectedIndex)
            // 旋转动画
            animatable.animateTo(
                targetValue = rotationAngle + 360f * 5, // 旋转5圈
                //tween默认先快后慢FastOutSlowInEasing
                animationSpec = tween(
                    durationMillis = 2000,
                //  easing = LinearEasing
                )
            )
            // 计算最终角度,使选中的项停在指针位置
            animatable.snapTo(rotationAngle)
            onSpinEnd()
        }
    }

    MyWheelView(
        modifier = modifier,
        segments = segments,
        selectedIndex = selectedIndex,
        rotationAngle = if (isSpinning) animatable.value else rotationAngle,
    )
}

WheelExample 圆盘旋转动画示例

ini 复制代码
val segments = listOf(
    WheelSegment("一等奖", Color.Red, "1000"),
    WheelSegment("二等奖", Color.Blue, "500"),
    WheelSegment("三等奖", Color.Green, "200"),
    WheelSegment("四等奖", Color.Yellow, "100"),
    WheelSegment("五等奖", Color.Magenta, "50"),
    WheelSegment("谢谢参与", Color.Cyan, "-999")
)

@Composable
fun WheelExample() {
    var selectedIndex by remember { mutableStateOf(-1) }
    var isSpinning by remember { mutableStateOf(false) }
    Box {
        Column(
            modifier = Modifier.fillMaxWidth(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            RotatableWheelView(
                modifier = Modifier.size(200.dp),
                segments = segments,
                selectedIndex = selectedIndex,
                isSpinning = isSpinning,
                onSpinEnd = { isSpinning = false }
            )
            Spacer(modifier = Modifier.height(10.dp))
            MainButton(
                text = "开始",
                onClick = {
                    selectedIndex = (0 until segments.size).random()
                    isSpinning = true
                },
                enabled = !isSpinning
            )
            MainText(
                text = if (isSpinning) "抽奖中..."
                else if (selectedIndex == -1) "请点击开始"
                else "结果: ${segments[selectedIndex].text}",
                modifier = Modifier.padding(16.dp)
            )
        }
        if (!isSpinning && selectedIndex != -1 && segments[selectedIndex].value.toInt() > 200) {
            WheelEggView(Modifier.matchParentSize())
        }
    }
}

TouchRotatableWheelView 触摸滑动判断组件

使用Modifier.pointerInput{detectDragGestures} 处理拖拽手势

animateDecay() 处理惯性动画

scss 复制代码
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
fun TouchRotatableWheelView(
    modifier: Modifier = Modifier,
    segments: List<WheelSegment> = listOf(),
    onRotationListener: (Boolean) -> Unit = {},
    onSegmentSelected: (Int) -> Unit = {}
) {
    var rotationAngle by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    var isDrag by remember { mutableStateOf(false) }
    val density = LocalDensity.current
    val c = remember {
        with(density) {
            (250.dp.toPx() * Math.PI).toFloat()
        }
    }
    //处理滑动惯性
    val velocityTracker = remember { MyVelocityTracker() }
    val scope = rememberCoroutineScope()
    var job: Job? = null
    Box(
        modifier = modifier
            .size(250.dp)
            .background(Color.Gray)
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = {
                        job?.cancel()
                        "onDragStart".log_e()
                        velocityTracker.clear()
                        velocityTracker.addPosition(it)
                        onRotationListener(true)
                        isDrag = true
                    },
                    onDrag = { change, dragAmount ->
                        offset += dragAmount
                        velocityTracker.addPosition(change.position, change.uptimeMillis)
                        val x = dragAmount.x
                        val y = dragAmount.y
                        //水平还是竖直 // >0 顺时针 <0 逆时针
                        //简略,如果更加细致以圆点为中心上下左右判断手势,效果更好...
                        if (x.absoluteValue > y.absoluteValue) {
                            val a = x / c * 360
                            rotationAngle += a
                        } else {
                            val a = y / c * 360
                            rotationAngle += a
                        }
                    },
                    onDragEnd = {
                        // 计算惯性动画目标值
                        val velocity = velocityTracker.calculateDirectionVelocity().second
                        "velocity = $velocity".log_e()
                        if (velocity > 0f) {
                            job = scope.launch {
                                animateDecay(
                                    initialValue = 0f,
                                    initialVelocity = velocity,
                                    animationSpec = SplineBasedFloatDecayAnimationSpec(density)
                                ) { value, _ ->
                                    if(isActive) {
                                        val a = value / c * 360
                                        rotationAngle += a
                                    }
                                }
                                onRotationListener(false)
                                onSegmentSelected.invoke(
                                    getSelectedIndex(
                                        segments.size,
                                        rotationAngle
                                    )
                                )
                                isDrag = false
                            }
                        } else {
                            onRotationListener(false)
                            onSegmentSelected.invoke(
                                getSelectedIndex(
                                    segments.size,
                                    rotationAngle
                                )
                            )
                            isDrag = false
                        }
                    }
                )
//                    launch {
//                        //处理平移缩放旋转手势
//                        detectTransformGestures { _, pan, _, rotation ->
//                            //滑动的距离和圆的周长比
//                            val distance = max(pan.x, pan.y)
//                            val a = distance / l * 360
//                            if (a > 0) {
//                                rotationAngle += a
//                            }
//                            "distance: $distance".log_e()
//                        }
//                    }
            }
    )
    {
        MyWheelView(
            modifier = Modifier.matchParentSize(),
            segments = segments,
            rotationAngle = rotationAngle,
        )
    }
}

TouchRotatableWheelViewExample 触摸旋转组件示例

ini 复制代码
@Composable
fun TouchRotatableWheelViewExample() {
    var selectedIndex by remember { mutableStateOf(-1) }
    var isSpinning by remember { mutableStateOf(false) }
    Box {
        Column(
            modifier = Modifier.fillMaxWidth(),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            TouchRotatableWheelView(
                segments = segments,
                onRotationListener = { rotation ->
                    isSpinning = rotation
                })
            { index ->
                selectedIndex = index
            }
            MainText(
                if (isSpinning) "旋转中..."
                else if (selectedIndex == -1) "请滑动转盘"
                else "结果: ${segments[selectedIndex].text}",
                modifier = Modifier.padding(16.dp)
            )
        }
        if (!isSpinning && selectedIndex != -1 && segments[selectedIndex].value.toInt() > 200) {
            WheelEggView(Modifier.matchParentSize())
        }
    }
}

WheelEggView 一二等奖 庆祝组件

默认随机50个Shape

scss 复制代码
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
fun WheelEggView(modifier: Modifier = Modifier, count: Int = 50) {
    val animate = remember { Animatable(1f) }
    val side = remember { mutableStateOf(false) }
    SideEffect {
        side.value = true
    }
    LaunchedEffect(side.value) {
        animate.animateTo(
            0f,
            animationSpec = tween(
                durationMillis = 1000,
                easing = LinearEasing
            )
        )
    }
    BoxWithConstraints(modifier = modifier) {
        val maxWidth = constraints.maxWidth
        val maxHeight = constraints.maxHeight
        Canvas(
            modifier = Modifier
                .width(constraints.maxWidth.dp)
                .height(constraints.maxHeight.dp)
        ) {
            for (i in 0 until count) {
                val x = (0 until maxWidth).random().toFloat()
                val y = (0 until maxHeight).random().toFloat()
                val size = 4.dp.roundToPx().toFloat()
                val shape = Shape.entries.random()
                val color = colors.random()
                when (shape) {
                    Shape.Circle -> {
                        drawCircle(
                            color = color.copy(alpha = animate.value),
                            radius = size,
                            center = Offset(x, y)
                        )
                    }

                    Shape.Rect -> {
                        drawRect(
                            color = color.copy(alpha = animate.value),
                            topLeft = Offset(x, y),
                            size = Size(size, size)
                        )
                    }
                    //绘制随机方向的三角图案
                    Shape.Triangle -> {
                        val path = Path().apply {
                            moveTo(x, y)
                            val index = (0..3).random()
                            when(index){
                                0->{
                                    lineTo(x + size * 2, y)
                                    lineTo(
                                        x + size,
                                        y + (2 * size * sin(
                                            Math.toRadians(
                                                (40 until 70).random().toDouble()
                                            )
                                        )).toFloat()
                                    )
                                }
                                1->{
                                    lineTo(x + size * 2, y)
                                    lineTo(
                                        x + size,
                                        y - (2 * size * sin(
                                            Math.toRadians(
                                                (40 until 70).random().toDouble()
                                            )
                                        )).toFloat()
                                    )
                                }
                                2->{
                                    lineTo(x, y+ size * 2)
                                    lineTo(
                                        x - (2 * size * sin(
                                            Math.toRadians(
                                                (40 until 70).random().toDouble()
                                            )
                                        )).toFloat(),
                                        y +size
                                    )
                                }
                                else->{
                                    lineTo(x, y+ size * 2)
                                    lineTo(
                                        x - (2 * size * sin(
                                            Math.toRadians(
                                                (40 until 70).random().toDouble()
                                            )
                                        )).toFloat(),
                                        y +size
                                    )
                                }
                            }

                            close()
                        }
                        drawPath(
                            path,
                            color = color.copy(alpha = animate.value),
                        )
                    }
                }
            }
        }
    }
}

enum class Shape {
    Circle,
    Rect,
    Triangle
}

MyVelocityTracker 惯性滑动工具类

kotlin 复制代码
/**
 * @param maximumVelocity 惯性滑动阈值
 */
class MyVelocityTracker(val maximumVelocity: Float = Float.MAX_VALUE) {
    private val velocityTracker = VelocityTracker()
    fun addPosition(position: Offset, timeMills: Long = System.currentTimeMillis()) {
        velocityTracker.addPosition(timeMills, position)
    }

    fun calculateVelocity(): Offset {
        val velocity = velocityTracker.calculateVelocity(
            Velocity.Zero.copy(
                x = maximumVelocity,
                y = maximumVelocity
            )
        )
        return Offset(velocity.x, velocity.y)
    }

    fun clear() {
        velocityTracker.resetTracking()
    }

    fun calculateDirectionVelocity(): Pair<Orientation, Float> {
        val velocity = velocityTracker.calculateVelocity(
            Velocity.Zero.copy(
                x = maximumVelocity,
                y = maximumVelocity
            )
        )
        val x = velocity.x
        val y = velocity.y
        return if (x.absoluteValue > y.absoluteValue) {
            Orientation.Horizontal to x
        } else {
            Orientation.Vertical to y
        }
    }
}
相关推荐
QING6184 小时前
Kotlin 类型转换与超类 Any 详解
android·kotlin·app
QING6185 小时前
一文带你了解 Kotlin infix 函数的基本用法和使用场景
android·kotlin·app
xiegwei14 小时前
Kotlin 和 spring-cloud-function 兼容问题
开发语言·kotlin·springcloud
_一条咸鱼_16 小时前
大厂Android面试秘籍:Activity 权限管理模块(七)
android·面试·android jetpack
lynn8570_blog16 小时前
通过uri获取文件路径手机适配
android·kotlin·android studio
安小牛21 小时前
Kotlin 学习--数组
javascript·学习·kotlin
stevenzqzq1 天前
kotlin扩展函数
android·开发语言·kotlin
Hello姜先森1 天前
Kotlin日常使用函数记录
android·开发语言·kotlin
zhangphil1 天前
Android Coil 3 Fetcher大批量Bitmap拼接成1张扁平宽图,Kotlin
android·kotlin
EnzoRay1 天前
Navigation的使用
android jetpack