Jetpack Compose 打造炫酷的倒计时 App

简介: Compose 动画打造的倒计时小程序

TikTik
41ade6b6840c4f28b9eb4437f99421ea~tplv-k3u1fbpfcp-zoom-1.image

项目中使用的都是Compose最基础的API,花时间不多,但完成效果还比较满意,可见Compose确实有助于提升UI开发效率,这里简单与大家分享一下实现过程。

<br/>

App实现

1. 画面构成

在这里插入图片描述

app由两个画面构成:

  • 输入画面(InputScreen) : <br/>通过数字软键盘输入时间,当新输入数字时,所有数字左移;backspace回退最近一次输入时,所有数字右移。类似计算器app的输入和显示逻辑。
  • 倒计时画面(CountdownScreen):<br/> 显示当前剩余时间并配有动画效果;根据剩余时间的不同,文字格式和大小会做出变化:最后10秒倒计时的文字也有更醒目的缩放动画。
more than 1h more than 1m & less than 1h less than 1m
b8480c6f84964270a067b8d4366059c0~tplv-k3u1fbpfcp-zoom-1.image 55e47c5d0166482db8cf62237aa145e0~tplv-k3u1fbpfcp-zoom-1.image 5079dc94fe224e0ab35319ffbe26fc2a~tplv-k3u1fbpfcp-zoom-1.image

state控制页面跳转

页面之间的跳转逻辑:

  • InputScreen完成输入后,点击底部Next,跳转到CountdownScreen进入倒计时
  • CountdownScreen点击底部Cancel,返回InputScreen

Compose没有ActivityFragment这样的页面管理单元,所谓的页面只不过是一个全屏的Composable,通常可以使用state实现。复杂的页面场景可以借助navigation-compose

enum class Screen {
    Input, Countdown
}

@Composable
fun MyApp() {

    var timeInSec = 0
    Surface(color = MaterialTheme.colors.background) {
        var screen by remember { mutableStateOf(Screen.Input) }

        Crossfade(targetState = screen) {
            when (screen) {
                Screen.Input -> InputScreen {
                    screen = Screen.CountdownScreen
                }
                Screen.Countdown -> CountdownScreen(timeInSec) {
                    screen = Screen.Input
                }
            }
        }
    }
}
  • screen: 使用state保存并监听当前页面的变化,
  • CrossfadeCrossfade可以淡入淡出的切换内部布局;内部根据screen切换不同页面。
  • timeInSec:InputScreen的输入存入timeInSec,并携带到CountdownScreen

2. 输入画面(InputScreen)

8d3ab75efae742d5abfbb5f32fcba8f5~tplv-k3u1fbpfcp-zoom-1.image

InputScreen包括以下元素:

  1. 输入结果:input-value
  2. 回退:backspace
  3. 软键盘:softkeyboard
  4. 底部:next

根据当前的输入结果,画面各元素会发生变化。

  • 当有输入结果时:next显示、backspace激活、input-value高亮;
  • 反之,next隐藏、backspace禁用、input-value低亮

state驱动UI刷新

如果用传统写法会比较啰嗦,需要在影响输入结果的地方设置监听,例如本例中需要分别监听backspace和next。当输入变化时命令式地去修改相关元素,页面复杂度会随着页面元素增多呈指数级增长。

使用Compose则简单得多,我们只需要将输入结果包装成state并监听,当state变化时,所有Composable重新执行、更新状态。即使元素增多也不会影响已有代码,复杂度不会增加。

var input by remember {
    mutableStateOf(listOf<Int>())
}
    
val hasCountdownValue = remember(input) {
    input.isNotEmpty()
}
  • mutableStateOf创建一个可变化的state,通过by代理进行订阅,当state变化时当前Composable会重新执行。
  • 由于Composable会反复执行,使用remember{}可以避免由于Composable的执行反复而反复创建state实例。
  • remember的参数变化时,block会重新执行,上面例子中,当input变化时,判断input是否为空并保存在hasCountdownValue中,供其他Composable参照。
Column() {

        ...
        
        Row(
            Modifier
                .fillMaxWidth()
                .height(100.dp)
                .padding(start = 30.dp, end = 30.dp)
        ) {
            //Input-value
            listOf(hou to "h", min to "m", sec to "s").forEach {
                DisplayTime(it.first, it.second, hasCountdownValue)
            }

            //Backspace
            Image(
                imageVector = Icons.Default.Backspace,
                contentDescription = null,
                colorFilter = ColorFilter.tint(
                    Color.Unspecified.copy(
                        //根据hasCountdownValue显示不同亮度
                        if (hasCountdownValue) 1.0f else 0.5f
                    )
                )
            )
        }

        ...

        //根据hasCountdownValue,是否显示next
        if (hasCountdownValue) {
            Image(
              imageVector = Icons.Default.PlayCircle,
                contentDescription = null,
                colorFilter = ColorFilter.tint(MaterialTheme.colors.primary)
            )
        }
    }

如上,声明UI的同时加入hasCountdownValue的判断逻辑,然后等待再次刷新就OK,无需像传统写法那样设置监听并命令式地更新UI。

3. 倒计时画面(CountdownScreen)

de11235d6f55420c87cb060383a5c93b~tplv-k3u1fbpfcp-zoom-1.image

CountdownScreen主要包括以下元素:

  1. 文字部分:显示hour、second、minutes 以及ms
  2. 氛围部分:多个不同类型的圆形动画
  3. 底部Cancel

使用animation计算倒计时

如何准确地计算倒计时呢?

最初的方案是使用flow计算倒计时,然后将flow转成state,驱动UI刷新:

private fun interval(sum: Long, step: Long): Flow<Long> = flow {
    while (sum > 0) {
        delay(step)
        sum -= step
        emit(sum)
    }
}

但是经过测试发现,由于协程切换也有开销,使用delay处理倒计时并不精确。

经过思考决定使用animation处理倒计时

var trigger by remember { mutableStateOf(timeInSec) }

val elapsed by animateIntAsState(
    targetValue = trigger * 1000,
    animationSpec = tween(timeInSec * 1000, easing = LinearEasing)
)

DisposableEffect(Unit) {
    trigger = 0
    onDispose { }
}
  • Compose的动画也是通过state驱动的, animateIntAsState定义动画、计算动画估值并转成state。
  • 动画由targetValue的变化触发启动。
  • animationSpec用来配置动画类型,例如这里通过tween配置一个线性的补间动画。duration设置为timeInSec * 1000 ,也就是倒计时时长的ms。
  • DisposableEffect用来在纯函数中执行副作用。如果参数发生变化,block中的逻辑会在每次重绘(Composition)时执行。 DisposableEffect(Unit)由于参数永远不会变化,意味着block只会在第一次上屏时只执行一次。
  • trigger初始状态为timeInSec(倒计时总时长),然后在第一次上屏时设置为0,targetValue变化触发了动画:从timeInSec*1000 执行到 0 ,duration为timeInSec*1000 ,动画结束时就是倒计时的结束,而且绝对精确,没有误差。

接下来只需要将elapsed换算成合适的文字显示就OK了

val (hou, min, sec) = remember(elapsed / 1000) {
    val elapsedInSec = elapsed / 1000
    val hou = elapsedInSec / 3600
    val min = elapsedInSec / 60 - hou * 60
    val sec = elapsedInSec % 60
    Triple(hou, min, sec)
}
...

字体动态变化

剩余时间的变化,带来文字内容和字体大小不同。这个实现非常简单,只要Composable中设置size的时候判断剩余时间就好了。


 //根据剩余时间设置字体大小
 val (size, labelSize) = when {
     hou > 0 -> 40.sp to 20.sp
     min > 0 -> 80.sp to 30.sp
     else -> 150.sp to 50.sp
 }
    
 ...
 Row() {
        if (hou > 0) {//当剩余时间不足一小时时,不显示h
            DisplayTime(
                hou.formatTime(),
                "h",
                fontSize = size,
                labelSize = labelSize
            )
        }
        if (min > 0) {//剩余时间不足1分钟,不显示m
            DisplayTime(
                min.formatTime(),
                "m",
                fontSize = size,
                labelSize = labelSize
            )
        }
        DisplayTime(
              sec.formatTime(),
                "s",
                fontSize = size,
                labelSize = labelSize
        )
    }

氛围动画

氛围动画对提高App质感很重要,app中使用了如下几种动画烘托氛围:

  • 正圆呼吸灯效果:1次/2秒
  • 半圆环跑马灯效果:1次/1秒
  • 雷达动画:倒计时结束时扫描进度100%
  • 文字缩放:倒计时10秒缩放,1次/1秒

这里使用transition同步多个动画

    val transition = rememberInfiniteTransition()
    var trigger by remember { mutableStateOf(0f) }

    //线性动画实现雷达动画
    val animateTween by animateFloatAsState(
        targetValue = trigger,
        animationSpec = tween(
            durationMillis = durationMills,
            easing = LinearEasing
        ),
    )

    //infiniteRepeatable+restart实现跑马灯
    val animatedRestart by transition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(tween(1000), RepeatMode.Restart)
    )
    
    //infiniteRepeatable+reverse实现呼吸灯
    val animatedReverse by transition.animateFloat(
        initialValue = 1.05f,
        targetValue = 0.95f,
        animationSpec = infiniteRepeatable(tween(2000), RepeatMode.Reverse)
    )
    
    //infiniteRepeatable+reverse实现文字缩放
    val animatedFont by transition.animateFloat(
        initialValue = 1.5f,
        targetValue = 0.8f,
        animationSpec = infiniteRepeatable(tween(500), RepeatMode.Reverse)
    )
    
  • rememberInfiniteTransition创建了一个repeatable的transition,transition通过animateXXX创建多个动画(state),同一个transition创建的动画保持同步。app中创建了3个动画:animatedRestartanimatedReverseanimatedFont
  • transition中也可以设置animationSpec。app中配置的infiniteRepeatable是一个repeat动画,可以通过参数设置duration以及RepeatMode

绘制圆环图形

接下来就可以基于上面创建的动画state绘制各种圆形的氛围了,通过不断地compoition实现动画效果。

Canvas(
     modifier = Modifier
            .align(Alignment.Center)
            .padding(16.dp)
            .size(350.dp)
) {
        val diameter = size.minDimension
        val radius = diameter / 2f
        val size = Size(radius * 2, radius * 2)

        //跑马灯半圆
        drawArc(
                color = color,
                startAngle = animatedRestart,
                sweepAngle = 150f,
                size = size,
                style = Stroke(15f),
        )
        
        //呼吸灯整圆
        drawCircle(
            color = secondColor,
            style = strokeReverse,
            radius = radius * animatedReverse
        )

        //雷达扇形
        drawArc(
            startAngle = 270f,
            sweepAngle = animateTween,
            brush = Brush.radialGradient(
                radius = radius,
                colors = listOf(
                    purple200.copy(0.3f),
                    teal200.copy(0.2f),
                    Color.White.copy(0.3f)
                ),
            ),
            useCenter = true,
            style = Fill,
        )
    }
  • Canvas{}可以绘制自定义图形。
  • drawArc用来绘制一个带角度的弧形,startAnglesweepAngle设置弧在圆上的 其实位置,这里设置startAngle为animatedRestart,根据state的变化实现动画效果。style设置为Stroke表示只绘制边框,设置为Fill则表示填充这个弧形区域形成扇形。
  • drawCircle用来绘制一个正圆,这里通过animatedReverse,改变半径实现呼吸灯效果
Note: 关于Compose动画的更多内容可以参考 《一文学会使用Jetpack Compose Animations》

<br/>

总结

Compose的核心是State驱动UI刷新,animation也是依靠state来实现动画。因此除了服务于视觉效果,animation还可以用来计算state。到这时才恍然大悟组织方在题目描述中提示可能会用到animation,其更主要的目的是用来精确计算countdown的最新状态。

项目地址:TikTik

目录
相关文章
|
7月前
|
存储 缓存 Android开发
安卓Jetpack Compose+Kotlin, 使用ExoPlayer播放多个【远程url】音频,搭配Okhttp库进行下载和缓存,播放完随机播放下一首
这是一个Kotlin项目,使用Jetpack Compose和ExoPlayer框架开发Android应用,功能是播放远程URL音频列表。应用会检查本地缓存,如果文件存在且大小与远程文件一致则使用缓存,否则下载文件并播放。播放完成后或遇到异常,会随机播放下一首音频,并在播放前随机设置播放速度(0.9到1.2倍速)。代码包括ViewModel,负责音频管理和播放逻辑,以及UI层,包含播放和停止按钮。
|
4月前
|
安全 Java Android开发
探索安卓应用开发的新趋势:Kotlin和Jetpack Compose
在安卓应用开发领域,随着技术的不断进步,新的编程语言和框架层出不穷。Kotlin作为一种现代的编程语言,因其简洁性和高效性正逐渐取代Java成为安卓开发的首选语言。同时,Jetpack Compose作为一个新的UI工具包,提供了一种声明式的UI设计方法,使得界面编写更加直观和灵活。本文将深入探讨Kotlin和Jetpack Compose的特点、优势以及如何结合使用它们来构建现代化的安卓应用。
115 4
|
5月前
|
Kubernetes Linux Docker
【Azure 应用服务】使用Docker Compose创建App Service遇见"Linux Version is too long. It cannot be more than 4000 characters"错误
【Azure 应用服务】使用Docker Compose创建App Service遇见"Linux Version is too long. It cannot be more than 4000 characters"错误
|
6月前
|
存储 移动开发 Android开发
使用kotlin Jetpack Compose框架开发安卓app, webview中h5如何访问手机存储上传文件
在Kotlin和Jetpack Compose中,集成WebView以支持HTML5页面访问手机存储及上传音频文件涉及关键步骤:1) 添加`READ_EXTERNAL_STORAGE`和`WRITE_EXTERNAL_STORAGE`权限,考虑Android 11的分区存储;2) 配置WebView允许JavaScript和文件访问,启用`javaScriptEnabled`、`allowFileAccess`等设置;3) HTML5页面使用`<input type="file">`让用户选择文件,利用File API;
|
7月前
|
JavaScript Java Android开发
kotlin安卓在Jetpack Compose 框架下跨组件通讯EventBus
**EventBus** 是一个Android事件总线库,简化组件间通信。要使用它,首先在Gradle中添加依赖`implementation &#39;org.greenrobot:eventbus:3.3.1&#39;`。然后,可选地定义事件类如`MessageEvent`。在活动或Fragment的`onCreate`中注册订阅者,在`onDestroy`中反注册。通过`@Subscribe`注解方法处理事件,如`onMessageEvent`。发送事件使用`EventBus.getDefault().post()`。
|
7月前
|
安全 JavaScript 前端开发
kotlin开发安卓app,JetPack Compose框架,给webview新增一个按钮,点击刷新网页
在Kotlin中开发Android应用,使用Jetpack Compose框架时,可以通过添加一个按钮到TopAppBar来实现WebView页面的刷新功能。按钮位于右上角,点击后调用`webViewState?.reload()`来刷新网页内容。以下是代码摘要:
|
7月前
|
JavaScript 前端开发 Android开发
kotlin安卓在Jetpack Compose 框架下使用webview , 网页中的JavaScript代码如何与native交互
在Jetpack Compose中使用Kotlin创建Webview组件,设置JavaScript交互:`@Composable`函数`ComposableWebView`加载网页并启用JavaScript。通过`addJavascriptInterface`添加`WebAppInterface`类,允许JavaScript调用Android方法如播放音频。当页面加载完成时,执行`onWebViewReady`回调。
|
7月前
|
监控 Android开发 数据安全/隐私保护
安卓kotlin JetPack Compose 实现摄像头监控画面变化并录制视频
在这个示例中,开发者正在使用Kotlin和Jetpack Compose构建一个Android应用程序,该程序 能够通过手机后置主摄像头录制视频、检测画面差异、实时预览并将视频上传至FTP服务器的Android应用
|
7月前
深入了解 Jetpack Compose 中的 Modifier
深入了解 Jetpack Compose 中的 Modifier
132 0
|
7月前
|
Android开发
Jetpack Compose: Hello Android
Jetpack Compose: Hello Android

热门文章

最新文章