使用Jetpack Compose在canvas上制作一个酷炫的时钟

使用Jetpack Compose在canvas上制作一个酷炫的时钟

在这篇博文中,我将解释我们如何使用Compose Canvas API来实现这一点。我还将介绍与计算圆上坐标以及在这些坐标周围绘制形状和文本相关的数学概念。这里使用的许多公式都是通过试验和错误推导出来的,因此一开始理解它们可能会有些困难。然而,我会通过图表来简化解释,以便更容易理解。希望你喜欢阅读这篇文章。

1.术语介绍

  • 秒和分钟刻度盘(seconds and minutes dials):这些是手表表盘上的旋转圆圈。两个刻度盘是相同的,只是半径不同。
  • 步数(steps):刻度盘被分为60个步骤,有两种类型的步骤:普通步和五步。
  • 步数标签(step Labels):五步间隔上标有值,例如[00,05,10…]。
  • 小时标签(hour Label):中央标签以24小时制表示当天的小时数。
  • 分钟-秒钟叠加(minutes-Second Overlay):为了突出当前的分钟和秒钟,一个圆角矩形被放置在表盘右侧中心位置。

2. 绘制秒和分钟刻度盘

本节再次分为三个子部分

  • 理解刻度盘
  • 绘制步骤
  • 绘制步骤标签

2.1 理解刻度盘

understanding dial

  • 秒针和分针的步数相同,但半径和每秒旋转速度不同。
  • 秒针每秒旋转6度,而分针每分钟旋转6度。
  • 每个表盘都由60个普通步骤和12个刻度为5的步骤组成。
  • 相邻两个普通步骤之间的夹角为6度。
  • 相邻两个刻度为5的步骤之间的夹角为30度。
  • 五个刻度标签(如[00, 05, 10…])具有一些额外间距,称为stepsLabelTopPadding,以确保适当间隔。

2.2 绘制步数


为了画出步骤,我们需要每个单独步骤的起始和结束偏移量,这可以使用以下公式计算得出:

x = r * cos(θ)
y = r * sin(θ)

然而,圆的坐标系与画布的坐标系不同,因此我们需要修改上述公式以获得画布上的x、y坐标。

x = center.x + r * cos(θ)
y = center.y - r * sin(θ)

在公式中,我们需要用弧度表示角度。我们需要将角度乘以π/180来得到弧度。因此,修改后的公式为:

x = center.x + r * cos(angleInDegrees * (π / 180))
y = center.y - r * sin(angleInDegrees * (π / 180))

每秒钟,每个步骤都会以一定的角度旋转,因此我们将在上述公式中加入一个“rotation”状态,该状态每秒钟被修改一次,以便旋转每个步骤。

x = center.x + r * cos((angleInDegrees + rotation) * (π / 180))
y = center.y - r * sin((angleInDegrees + rotation) * (π / 180))

2.3 绘制步骤标签

  • 为了在画布上绘制文本,我们需要绘制文本的左上角偏移位置。
  • 在计算时,我们还需要考虑标签的宽度和高度,以便将标签正确位置于步骤中心。

绘制秒钟和分钟刻度的代码片段和输出结果。
Draw Steps and Labels Output

//data class for wrapping dial customization
data class DialStyle(
    val stepsWidth: Dp = 1.2.dp,
    val stepsColor: Color = Color.Black,
    val normalStepsLineHeight: Dp = 8.dp,
    val fiveStepsLineHeight: Dp = 16.dp,
    val stepsTextStyle: TextStyle = TextStyle(),
    val stepsLabelTopPadding: Dp = 12.dp,
)

data class ClockStyle(
    val secondsDialStyle: DialStyle = DialStyle(),
)

@OptIn(ExperimentalTextApi::class)
@Composable
fun Clock(
    modifier: Modifier = Modifier.size(320.dp),
    clockStyle: ClockStyle = ClockStyle()
) {
    val textMeasurer = rememberTextMeasurer()

    var minuteRotation by remember { mutableStateOf(0f) }

    var secondRotation by remember { mutableStateOf(0f) }

    //secondRotation is updated by 6 degree clockwise every one second
    //here rotation is in negative, in order to get clockwise rotation
    LaunchedEffect(key1 = true) {
        while (true) {
            //in-order to get smooth transition we are updating rotation angle every 16ms
            //1000ms -> 6 degree
            //16ms -> 0.096
            delay(16)
            secondRotation -= 0.096f
        }
    }

    //minuteRotation is updated by 0.1 degree clockwise every one second
    //here rotation is in negative, in order to get clockwise rotation
    LaunchedEffect(key1 = true) {
        while (true) {
            delay(1000)
            minuteRotation -= 0.1f
        }
    }

    Canvas(
        modifier = modifier
    ) {
        val outerRadius = minOf(this.size.width, this.size.height) / 2f
        val innerRadius = outerRadius - 60.dp.toPx()

        //Seconds Dial
        dial(
            radius = outerRadius,
            rotation = secondRotation,
            textMeasurer = textMeasurer,
            dialStyle = clockStyle.secondsDialStyle
        )

        //Minute Dial
        dial(
            radius = innerRadius,
            rotation = minuteRotation,
            textMeasurer = textMeasurer,
            dialStyle = clockStyle.minutesDialStyle
        )
    }
}


@OptIn(ExperimentalTextApi::class)
fun DrawScope.dial(
    radius: Float,
    rotation: Float,
    textMeasurer: TextMeasurer,
    dialStyle: DialStyle = DialStyle()
) {
    var stepsAngle = 0

    //this will draw 60 steps
    repeat(60) { steps ->

        //fiveStep lineHeight > normalStep lineHeight
        val stepsHeight = if (steps % 5 == 0) {
            dialStyle.fiveStepsLineHeight.toPx()
        } else {
            dialStyle.normalStepsLineHeight.toPx()
        }

        //calculate steps, start and end offset
        val stepsStartOffset = Offset(
            x = center.x + (radius * cos((stepsAngle + rotation) * (Math.PI / 180f))).toFloat(),
            y = center.y - (radius * sin((stepsAngle + rotation) * (Math.PI / 180))).toFloat()
        )
        val stepsEndOffset = Offset(
            x = center.x + (radius - stepsHeight) * cos(
                (stepsAngle + rotation) * (Math.PI / 180)
            ).toFloat(),
            y = center.y - (radius - stepsHeight) * sin(
                (stepsAngle + rotation) * (Math.PI / 180)
            ).toFloat()
        )

        //draw step
        drawLine(
            color = dialStyle.stepsColor,
            start = stepsStartOffset,
            end = stepsEndOffset,
            strokeWidth = dialStyle.stepsWidth.toPx(),
            cap = StrokeCap.Round
        )

        //draw steps labels
        if (steps % 5 == 0) {
            //measure the given label width and height
            val stepsLabel = String.format("%02d", steps)
            val stepsLabelTextLayout = textMeasurer.measure(
                text = buildAnnotatedString { append(stepsLabel) },
                style = dialStyle.stepsTextStyle
            )

            //calculate the offset
            val stepsLabelOffset = Offset(
                x = center.x + (radius - stepsHeight - dialStyle.stepsLabelTopPadding.toPx()) * cos(
                    (stepsAngle + rotation) * (Math.PI / 180)
                ).toFloat(),
                y = center.y - (radius - stepsHeight - dialStyle.stepsLabelTopPadding.toPx()) * sin(
                    (stepsAngle + rotation) * (Math.PI / 180)
                ).toFloat()
            )

            //subtract the label width and height to position label at the center of the step
            val stepsLabelTopLeft = Offset(
                stepsLabelOffset.x - ((stepsLabelTextLayout.size.width) / 2f),
                stepsLabelOffset.y - (stepsLabelTextLayout.size.height / 2f)
            )

            drawText(
                textMeasurer = textMeasurer,
                text = stepsLabel,
                topLeft = stepsLabelTopLeft,
                style = dialStyle.stepsTextStyle
            )
        }
        stepsAngle += 6
    }
}

3. 绘制小时标签

  • 为了绘制叠加层,我们将利用路径函数lineTocubicTo,这使我们能够创建圆角。
  • 在绘制圆角时,我们需要考虑分钟和秒标签的宽度,以及步长大小。
    hour label
//draw hour
val hourString = String.format("%02d", hour)

val hourTextMeasureOutput = textMeasurer.measure(
    text = buildAnnotatedString { append(hourString) },
    style = clockStyle.hourLabelStyle
)

val hourTopLeft = Offset(
    x = this.center.x - (hourTextMeasureOutput.size.width / 2),
    y = this.center.y - (hourTextMeasureOutput.size.height / 2)
)

drawText(
    textMeasurer = textMeasurer,
    text = hourString,
    topLeft = hourTopLeft,
    style = clockStyle.hourLabelStyle
)

3. 绘制分钟秒叠加层

  • 为了绘制叠加层,我们将利用路径函数lineTo和cubicTo,这些函数可以让我们创建圆角。
  • 在绘制圆角时,我们需要考虑分钟和秒标签的宽度,以及步长。
//drawing minute-second overlay
val minuteHandOverlayPath = Path().apply {
    val startOffset = Offset(
        x = center.x + (outerRadius * cos(8f * Math.PI / 180f)).toFloat(),
        y = center.y - (outerRadius * sin(8f * Math.PI / 180f)).toFloat(),
    )
    val endOffset = Offset(
        x = center.x + (outerRadius * cos(-8f * Math.PI / 180f)).toFloat(),
        y = center.y - (outerRadius * sin(-8f * Math.PI / 180f)).toFloat(),
    )
    val overlayRadius = (endOffset.y - startOffset.y) / 2f

    val secondsLabelMaxWidth = textMeasurer.measure(
        text = buildAnnotatedString { append("60") },
        style = clockStyle.secondsDialStyle.stepsTextStyle
    ).size.width

    val minutesLabelMaxWidth = textMeasurer.measure(
        text = buildAnnotatedString { append("60") },
        style = clockStyle.minutesDialStyle.stepsTextStyle
    ).size.width

    val overlayLineX =
        size.width - clockStyle.secondsDialStyle.fiveStepsLineHeight.toPx() - clockStyle.secondsDialStyle.stepsLabelTopPadding.toPx() - secondsLabelMaxWidth - clockStyle.minutesDialStyle.fiveStepsLineHeight.toPx() - clockStyle.minutesDialStyle.stepsLabelTopPadding.toPx() - (minutesLabelMaxWidth /2f)
    moveTo(x = startOffset.x, y = startOffset.y)
    lineTo(x = overlayLineX, y = startOffset.y)
    cubicTo(
        x1 = overlayLineX - overlayRadius,
        y1 = startOffset.y,
        x2 = overlayLineX - overlayRadius,
        y2 = endOffset.y,
        x3 = overlayLineX,
        y3 = endOffset.y
    )
    lineTo(endOffset.x, endOffset.y)
}

drawPath(
  path = minuteHandOverlayPath,
  color = clockStyle.overlayStrokeColor,
  style = Stroke(width = clockStyle.overlayStrokeWidth.toPx(),)
)

您可以根据您的喜好自定义clockStyle来更新标签的字体家族、大小和颜色。

最后来一张酷炫效果图

GitHub

https://github.com/nikhil-mandlik-dev/watchface

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
要实现类似抖音上下滑动切换视频的功能,可以使用Jetpack Compose中的`LazyColumn`和`Pager`组件。 首先,我们需要准备一些视频数据,可以使用一个包含视频URL的列表: ```kotlin val videos = listOf( "https://example.com/video1.mp4", "https://example.com/video2.mp4", "https://example.com/video3.mp4", // ... ) ``` 然后,我们可以使用`LazyColumn`和`Pager`组件来实现上下滑动和视频切换的功能: ```kotlin LazyColumn { val pagerState = rememberPagerState(pageCount = videos.size) items(count = videos.size) { index -> val videoUrl = videos[index] Pager( state = pagerState, modifier = Modifier.fillMaxWidth(), ) { VideoPlayer(videoUrl) } } } ``` 在上面的代码中,我们首先创建了一个`PagerState`,用于管理视频的切换。然后,我们使用`LazyColumn`组件创建一个垂直滚动列表,每个列表项都是一个`Pager`组件,用于显示一个视频。`Pager`组件的`state`属性绑定了`PagerState`,`modifier`属性设置了组件的宽度为最大,这样视频就可以充满屏幕了。`Pager`组件的内容是一个自定义的`VideoPlayer`组件,用于播放视频。 最后,我们需要实现`VideoPlayer`组件,可以使用`VideoView`和`ExoPlayer`来播放视频。下面是一个简单的`VideoPlayer`组件的实现: ```kotlin @Composable fun VideoPlayer(url: String) { val context = LocalContext.current val videoView = remember { VideoView(context).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) setMediaController(MediaController(context)) setOnPreparedListener(MediaPlayer.OnPreparedListener { mp -> mp.isLooping = true }) } } AndroidView( factory = { videoView }, update = { it.setVideoURI(Uri.parse(url)) it.start() } ) } ``` 在上面的代码中,我们首先使用`remember`来创建一个`VideoView`实例,然后使用`AndroidView`组件将其包装成一个Compose组件。在`AndroidView`组件的`update`块中,我们设置`VideoView`的视频URL并开始播放。这样就可以在`Pager`组件中显示一个视频并自动播放了。 以上就是使用Jetpack Compose实现类似抖音上下滑动切换视频的简单示例。当然,这只是一个基础实现,你还可以根据自己的需求进行扩展和优化。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Calvin880828

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值