查看原文
其他

Jetpack Compose竟能写出如此炫酷的倒计时APP!

(给安卓开发精选加星标)

转自:掘金 fundroid

https://juejin.cn/post/6936918974399512612

# Compose开发者挑战赛二周目

为配合Jetpack Compose beta版的发布,Google官方发起了Compose开发者挑战赛活动,目前已经入二周目 android-dev-challenge-2

第二周的题目是使用Compose实现倒计时app 。题目出的非常妥当,难度不高,但是能引导大家有针对性地去学习Compose的某些特性,比如这个app的实现需要大家学习和了解state以及animations的使用。

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

以下是我完成的项目:TikTik

图片过大,截图处理

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

# App实现

1. 画面构成

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

2. 输入画面(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()
}
  • 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)

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》

# 总结

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

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

项目地址:TikTik


- EOF -


推荐阅读  点击标题可跳转

1、Flutter 2.0 来了!成为跨平台开发终极之选

2、带你全面了解 Flutter,它好在哪里?它的坑在哪里?应该怎么学?

3、国内大厂在移动端跨平台的框架接入分析


看完本文有收获?请分享给更多人

 推荐关注「安卓开发精选」,提升安卓开发技术

点赞和在看就是最大的支持❤️

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存