使用Jetpack Compose竟能做出如此漂亮的倒计时APP

在这里插入图片描述

Compose开发者挑战赛二周目


迎合Jetpack Compose beta版的发布,Google官方发起了Compose开发者挑战赛活动,目前已经入二周目 android-dev-challenge-2
在这里插入图片描述
第二周的题目是使用Compose实现倒计时app 。题目出的非常妥当,难度不高,但是能引导大家有针对性地去学习Compose的某些特性,比如这个app的实现需要大家学习和了解state以及animations的使用。

对Compose开发者挑战赛及其参加方法有兴趣的朋友可以参考:Jetpack Compose 开发挑战赛


参赛项目:TikTik


废话少说,先看一下我提交的项目:TikTik

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


项目实现


画面构成

在这里插入图片描述
app由两个画面构成:

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

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

输入画面(InputScreen)

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()
}
  • mutableStateOfmutableStateOf创建一个可变化的state,通过by代理进行订阅,当state变化时当前Composable会重新执行。

  • remember{}:由于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。

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

接下来只需要将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
  • infiniteRepeatable: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: 关于动画的更多内容可以参考 Jetpack Compose Animations 超简单教程


总结


Compose的核心是State驱动UI刷新,animation也是基于state驱动的,因此除了服务于视觉,animation还可以用来计算state。到这时才恍然大悟为什么组织方提示可能会用到animation,更主要的作用是用来精确计算countdown的最新状态。

CountdownTimer这样的项目很适合拿来给新技术练手,第二周挑战的截止日是3月10日,而且后面还有2个挑战,都是以鼓励新手为主所以难度应该不会很高,如果你还没有接触过Compose,不妨趁这个机会尝尝鲜吧~

项目地址:TikTik

  • 9
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 8
    评论
Jetpack Compose for Desktop 中使用 JNI(Java Native Interface)可以让你与本地代码进行交互,调用 C/C++ 函数或访问本地库。以下是使用 JNI 在 Jetpack Compose for Desktop 中进行 JNI 开发的基本步骤: 1. 创建本地代码库:首先,你需要使用 C/C++ 编写本地代码库。可以使用你喜欢的 C/C++ 编译器将代码编译成动态链接库(.so 或 .dll 文件)。 2. 创建 Java 接口:在你的 Jetpack Compose for Desktop 项目中,创建一个 Java 接口来声明与本地代码库交互的函数。这个接口将作为 Java 和本地代码之间的桥梁。 3. 加载本地库:在你的 Jetpack Compose for Desktop 项目中,使用 `System.loadLibrary()` 或 `System.load()` 方法加载你的本地库。 4. 实现 Java 接口:创建一个 Java 类来实现你在第二步创建的 Java 接口。在这个类中,使用 `native` 关键字标记与本地代码库交互的函数。 5. 生成头文件:使用 `javac -h` 命令生成头文件(.h 文件)。这个命令会根据你在第二步创建的 Java 接口自动生成对应的头文件。 6. 实现本地代码:使用你喜欢的 C/C++ 编辑器打开生成的头文件,并实现其中声明的函数。确保函数签名和参数类型与 Java 接口中的一致。 7. 编译本地代码:使用你喜欢的 C/C++ 编译器编译你的本地代码,并生成动态链接库文件。 8. 调用本地函数:在你的 Jetpack Compose for Desktop 项目中,可以通过调用你在第二步创建的 Java 接口实现的函数来调用本地代码。 需要注意的是,JNI 开发需要一定的 C/C++ 编程经验,以及对 Java 和本地代码交互的机制有一定的了解。在使用 JNI 进行开发时,还需要注意内存管理和线程安全性等问题。确保在使用完本地资源后正确释放它们,以避免内存泄漏或其他问题的发生。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

fundroid

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

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

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

打赏作者

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

抵扣说明:

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

余额充值