查看原文
其他

Jetpack Compose第二周挑战赛,做一个倒计时器

Alpinist Wang 郭霖 2022-12-14


/   今日科技快讯   /

美图近日公布,公司于2021年3月5日在公开市场交易中购买了15000单位的以太币和379.12单位的比特币,这两种加密货币的总对价分别约为2210万美元和1790万美元,共涉资4000万美元(约合2.6亿人民币)。

/   作者简介   /

本篇文章来自Alpinist Wang同学投稿,分享了他使用Jetpack Compose编写一个倒计时器,这也是Jetpack Compose挑战赛第二周的课题,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章!

Alpinist Wang的博客地址:
https://blog.csdn.net/AlpinistWang

/   正文   /

受郭神鼓舞,我也参加了一下 Android 开发者挑战赛。本周的题目是用 Compose 写一个 Countdown Timer。


虽然是个小项目,但 Compose 的资料实在是太少了,不断地摸索,加上同事的帮助,花费了一天的工夫才做出来,效果如下:

数据结构

首先分析数据结构,我们需要保存用户设置的总时间、当前倒计时剩余时间。除此之外就没有其他数据需要保存了。

TimerViewModel 类如下:

// Max input length limit, it's used to prevent number grows too big.
const val MAX_INPUT_LENGTH = 5

class TimerViewModel : ViewModel() {

    /**
     * Total time user set in seconds
     */
    var totalTime: Long by mutableStateOf(0)

    /**
     * Time left during countdown in seconds
     */
    var timeLeft: Long by mutableStateOf(0)

    /**
     * Update value when EditText content changed
     * @param text new content in EditText
     */
    fun updateValue(text: String) {
        // Just in case the number is too big
        if (text.length > MAX_INPUT_LENGTH) return
        // Remove non-numeric elements
        var value = text.replace("\\D".toRegex(), "")
        // Zero cannot appear in the first place
        if (value.startsWith("0")) value = value.substring(1)
        // Set a default value to prevent NumberFormatException
        if (value.isBlank()) value = "0"
        totalTime = value.toLong()
        timeLeft = value.toLong()
    }
}


其中,updateValue 函数用于当用户输入倒计时总秒数后,更新 TimerViewModel 中的 totalTime 和 timeLeft 的值。

为了防止数字过大,我们只允许用户输入 5 位数,并且用正则表达式过滤掉用户输入的小数点、负号、逗号分隔符等非数值。并且数字首位不允许出现 0。经过层层处理,将安全的数值赋给 totalTime 和 timeLeft。

倒计时功能

实现倒计时有很多种方式,比如:

  • 我们熟悉的 handler.postDelayed 的方式

  • 在协程中 repeat + delay 的方式

  • 使用 ValueAnimator 的方式


我采用的是第三种方式,因为动画相对来说较容易控制,pause、resume、cancel 函数都是现成的,可以很方便的实现暂停、继续、停止等功能。

AnimatorController 类如下:

class AnimatorController(private val viewModel: TimerViewModel) {

    private var valueAnimator: ValueAnimator? = null

    fun start() {
        if (viewModel.totalTime == 0L) return
        if (valueAnimator == null) {
            // Animator: totalTime -> 0
            valueAnimator = ValueAnimator.ofInt(viewModel.totalTime.toInt(), 0)
            valueAnimator?.interpolator = LinearInterpolator()
            // Update timeLeft in ViewModel
            valueAnimator?.addUpdateListener {
                viewModel.timeLeft = (it.animatedValue as Int).toLong()
            }
            valueAnimator?.addListener(object : AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: Animator?) {
                    super.onAnimationEnd(animation)
                    complete()
                }
            })
        } else {
            valueAnimator?.setIntValues(viewModel.totalTime.toInt(), 0)
        }
        // (LinearInterpolator + duration) aim to set the interval as 1 second.
        valueAnimator?.duration = viewModel.totalTime * 1000L
        valueAnimator?.start()
    }

    fun pause() {
        valueAnimator?.pause()
    }

    fun resume() {
        valueAnimator?.resume()
    }

    fun stop() {
        valueAnimator?.cancel()
        viewModel.timeLeft = 0
    }

    fun complete() {
        viewModel.totalTime = 0
    }
}


在这个类中,我们处理了动画的启动、暂停、恢复、停止和完成五个功能。通过将 ValueAnimator 设置为在 totalTime 秒内从 totalTime 线性变化到 0 的方式设置出动画的间隔时间为 1s。

为了方便使用,我们将创建的 AnimatorController 对象放到 TimerViewModel 中:

class TimerViewModel : ViewModel() {
    //...

    var animatorController = AnimatorController(this)
}


状态模式

分析可知,倒计时 App 可分为四个状态:

  • 尚未开始

  • 已经开始

  • 暂停

  • 完成


由此,我们很容易想到用状态模式来设计此 App,只要为每个状态创建一个状态类,就可以减少大量的 if-else 语句和 when 语句。

状态模式(State Pattern):当一个对象的内在状态改变时允许改变其行为,这个对象看起来像是改变了其类。

在不同状态下,App 的表现和行为是不同的,我们先将这些不同的部分提取到接口中,大致有如下几个方法:

interface IStatus {
    /**
     * The content string displayed in Start Button.
     * include: Start, Pause, Resume.
     */
    fun startButtonDisplayString(): String

    /**
     * The behaviour when click Start Button.
     */
    fun clickStartButton()

    /**
     * Stop Button enable status
     */
    fun stopButtonEnabled(): Boolean

    /**
     * The behaviour when click Stop Button.
     */
    fun clickStopButton()

    /**
     * Show or hide EditText
     */
    fun showEditText(): Boolean
}

接口中抽出了五个函数,对应 App 在不同状态下的表现和行为:

  • fun startButtonDisplayString(): String 用于控制 Start 按钮上的文字显示,在尚未开始/完成状态下,按钮显示的文字为 “Start”,在已经开始状态下,按钮显示文字为 “Pause”,在暂停状态下,按钮显示文字为 “Resume”。

  • fun clickStartButton() 用于控制 Start 按钮的点击事件,在尚未开始/完成状态下,点击 Start 按钮启动 ValueAnimator,在已经开始状态下,点击 Start 按钮暂停 ValueAnimator,在暂停状态下,点击 Start 按钮恢复 ValueAnimator。

  • fun stopButtonEnabled(): Boolean 用于控制 Stop 按钮是否可点击,在尚未开始/完成状态下,Stop 按钮不可点击,在已经开始/暂停状态下,Stop 按钮可点击。

  • fun clickStopButton() 用于控制 Stop 按钮的点击事件,在尚未开始/完成状态下,Stop 按钮不可点击,点击事件为空,在已经开始/暂停状态下,点击 Stop 按钮停止 ValueAnimator。

  • fun showEditText(): Boolean 用于控制 EditText 的显示和隐藏,在尚未开始/完成状态下,EditText 显示,在已经开始/暂停状态下,EditText 隐藏。


通过以上分析,我们可以写出以下四个状态类:

尚未开始状态:

class NotStartedStatus(private val viewModel: TimerViewModel) : IStatus {

    override fun startButtonDisplayString() = "Start"

    override fun clickStartButton() = viewModel.animatorController.start()

    override fun stopButtonEnabled() = false

    override fun clickStopButton() {}

    override fun showEditText() = true
}


已经开始状态:

class StartedStatus(private val viewModel: TimerViewModel) : IStatus {

    override fun startButtonDisplayString() = "Pause"

    override fun clickStartButton() = viewModel.animatorController.pause()

    override fun stopButtonEnabled() = true

    override fun clickStopButton() = viewModel.animatorController.stop()

    override fun showEditText() = false
}


暂停状态:

class PausedStatus(private val viewModel: TimerViewModel) : IStatus {

    override fun startButtonDisplayString() = "Resume"

    override fun clickStartButton() = viewModel.animatorController.resume()

    override fun stopButtonEnabled() = true

    override fun clickStopButton() = viewModel.animatorController.stop()

    override fun showEditText() = false
}


完成状态:

class CompletedStatus(private val viewModel: TimerViewModel) : IStatus {

    override fun startButtonDisplayString() = "Start"

    override fun clickStartButton() = viewModel.animatorController.start()

    override fun stopButtonEnabled() = false

    override fun clickStopButton() {}

    override fun showEditText() = true
}


同样地,将状态值保存到 ViewModel 中:

class TimerViewModel : ViewModel() {
    //...

    var status: IStatus by mutableStateOf(NotStartedStatus(this))
}

最后,因为四种状态的改变和动画的状态是息息相关的,所以我们可以将状态转移的代码添加到 AnimatorController 类中:

class AnimatorController(private val viewModel: TimerViewModel) {
    //...

    fun start() {
        //...
        viewModel.status = StartedStatus(viewModel)
    }

    fun pause() {
        //...
        viewModel.status = PausedStatus(viewModel)
    }

    fun resume() {
        //...
        viewModel.status = StartedStatus(viewModel)
    }

    fun stop() {
        //...
        viewModel.status = NotStartedStatus(viewModel)
    }

    fun complete() {
        //...
        viewModel.status = CompletedStatus(viewModel)
    }
}


Compose 布局

整个布局中,除了时钟的绘制稍微复杂一点外,其他的 UI 都还比较简单。时钟的绘制我们稍后再讲,先从简单的讲起。

TimeLeftText

Compose 中,对应 TextView 的函数是 Text,展示剩余时间的 Text 如下:

@Composable
private fun TimeLeftText(viewModel: TimerViewModel) {
    Text(
        text = TimeFormatUtils.formatTime(viewModel.timeLeft),
        modifier = Modifier.padding(16.dp)
    )
}


通过 text 属性为其设置文字,modifier 属性为其添加了一个 16dp 的 padding。

其中,TimeFormatUtils 工具类代码如下:

object TimeFormatUtils {

    fun formatTime(time: Long): String {
        var value = time
        val seconds = value % 60
        value /= 60
        val minutes = value % 60
        value /= 60
        val hours = value % 60
        return String.format("%02d:%02d:%02d", hours, minutes, seconds)
    }
}


此工具用于格式化时间,测试类:

class TimeFormatUtilsTest : TestCase() {
    @Test
    fun test() {
        Assert.assertEquals("00:00:00", TimeFormatUtils.formatTime(0))
        Assert.assertEquals("00:00:30", TimeFormatUtils.formatTime(30))
        Assert.assertEquals("00:01:00", TimeFormatUtils.formatTime(60))
        Assert.assertEquals("00:10:30", TimeFormatUtils.formatTime(630))
        Assert.assertEquals("01:40:00", TimeFormatUtils.formatTime(6000))
        Assert.assertEquals("27:46:39", TimeFormatUtils.formatTime(99999))
    }
}


EditText

Compose 中,对应 EditText 的函数是 TextField,本例中,TextField 用于提供给用户输入倒计时总秒数。代码如下:

@Composable
private fun EditText(viewModel: TimerViewModel) {
    Box(
        modifier = Modifier
            .size(300.dp, 120.dp),
        contentAlignment = Alignment.Center
    ) {
        if (viewModel.status.showEditText()) {
            TextField(
                modifier = Modifier
                    .size(200.dp, 60.dp),
                value = if (viewModel.totalTime == 0L) "" else viewModel.totalTime.toString(),
                onValueChange = viewModel::updateValue,
                label = { Text("Countdown Seconds") },
                maxLines = 1,
                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
            )
        }
    }
}


其中,我们通过状态类中的 showEditText 函数来控制是否需要绘制 TextField,需要注意的是,Compose 中没有 View 的可见行这一概念(也可能是我没找到…),只能通过 if 条件句来控制 View 是否绘制。这在实现 GONE 效果时非常方便,View 也比以前的 GONE 效果消失得更彻底,但本例中,我想实现的效果其实是 INVISIBLE,因为 TextField 的 GONE 效果会导致 Column 中其他控件移位。

在 Compose 中,想要实现 INVISIBLE 效果就只能在 View 外层加一层嵌套,使其仍然占有之前控件的位置。Compose 中的 Box 函数类似之前的 FrameLayout 效果。(如果读者有更好的实现方案欢迎指正。)

当 TextField 中的值将要发生改变时,onValueChange 代码块就会被调用,我们通过 viewModel 的 updateValue 函数干预此过程,保证 viewModel 中只会赋上安全的值。

keyboardOptions 属性用于控制输入类型,虽然这里指定为输入 Number 类型了,但小数点、负号、逗号分隔符无法被过滤掉,这也是 updateValue 中使用正则表达式对输入的字符再过滤一次的原因。

StartButton

在 Compose 中,Button 不再是一个单纯的 View,Button 函数的最后一个参数是 RowScope,它更像是一个 ViewGroup,其中的内容由我们自己定义:

@Composable
private fun StartButton(viewModel: TimerViewModel) {
    Button(
        modifier = Modifier
            .width(150.dp)
            .padding(16.dp),
        enabled = viewModel.totalTime > 0,
        onClick = viewModel.status::clickStartButton
    ) {
        Text(text = viewModel.status.startButtonDisplayString())
    }
}


比如此例中,我们在 Button 中绘制了一个 Text,这样去实现一个普通的 Button 效果。Compose 中的 Button 使用起来更为灵活,也就是说,为 XXLayout 添加点击事件的时代已经过去,在 Compose 中,可以直接使用 Button 应付这类场景。

StopButton

StopButton 和 StartButton 是类似的:

@Composable
private fun StopButton(viewModel: TimerViewModel) {
    Button(
        modifier = Modifier
            .width(150.dp)
            .padding(16.dp),
        enabled = viewModel.status.stopButtonEnabled(),
        onClick = viewModel.status::clickStopButton
    ) {
        Text(text = "Stop")
    }
}


读者可能会产生疑惑,我们在写这几个控件时,只在初始化的时候定义了一下控件的状态,如显示的文字、enabled 状态等。却没有看到 viewModel.status 改变后,更新控件状态的代码。但实际上此时控件的状态已经会自动随着 viewModel.status 的改变而改变了。Compose 是如何做到这点的呢?

魔法就在这一句中:

var status: IStatus by mutableStateOf(NotStartedStatus(this))

只要是通过 by mutableStateOf 代理的属性,Compose 中使用了此属性的组件就会与此属性自动建立起订阅关系,当此属性发生改变时,Compose 中使用此属性的位置就会自动更新。这和 DataBinding、LiveData 等是类似的,都是观察者模式的运用。

Scaffold

以上各个控件都已经写好,只需将他们组合起来就行了:

class MainActivity : AppCompatActivity() {
    private val viewModel: TimerViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyTheme {
                MyApp()
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        // Release memory
        viewModel.animatorController.stop()
    }
}

// Start building your app here!
@Composable
fun MyApp() {
    val viewModel: TimerViewModel = viewModel()
    Scaffold(
        Modifier.fillMaxSize(),
        topBar = {
            TopAppBar(
                title = {
                    Text(
                        text = stringResource(id = R.string.app_name)
                    )
                }
            )
        },
    ) {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            TimeLeftText(viewModel)
            EditText(viewModel)
            Row {
                StartButton(viewModel)
                StopButton(viewModel)
            }
        }
    }
}


其中,Scaffold 是 Material Design 中的概念,它通常包含一个 topBar、一个 bottomBar、一个 floatingActionButton ,剩余部分称之为 body。

在此例中,我们给 topBar 声明为带一个 Text 的 TopAppBar,实现之前 ActionBar 的效果。

控件的布局采用了基础的 Column 和 Row(列和行),分别对应之前 LinearLayout 的 android:orientation="vertical" 和 android:orientation="horizontal"。

Ok,到这里我们可以先运行一下看看效果了,如下图所示:


可以看出,我们需要的功能都成功实现了。

接下来我们在中心区域绘制一个时钟,装饰一下我们的 App,顺便看下如何用 Compose 自定义 View。

绘制时钟

绘制时钟前,需要先在 IStatus 中新增一个方法:

interface IStatus {
    //...

    /**
     * Sweep angle of progress circle
     */
    fun progressSweepAngle(): Float
}

此方法用于表示圆环扫过的度数值。

在 StartedStatus 和 PausedStatus 中,此数值的计算方式为:

override fun progressSweepAngle() = viewModel.timeLeft * 1.0f / viewModel.totalTime * 360

在 NotStartedStatus 中,此数值为:

override fun progressSweepAngle() = if (viewModel.totalTime > 0) 360f else 0f

在 CompletedStatus 中,此数值为 0f:

override fun progressSweepAngle() = 0f

然后再通过此数值绘制对应的形状即可。

Compose 中自定义 View 需要使用 androidx.compose.foundation.Canvas 类,其中的 drawXXX 方法与之前 Android 中的 Canvas 都是类似的。Compose 之所以要写一个自己的 Canvas 类,而不是直接使用 Android 中现成的 Canvas 类,就是为了使 Compose 不需要依赖 Android 平台,为以后实现跨平台先铺好路。

@Composable
fun ProgressCircle(viewModel: TimerViewModel) {
    // Circle diameter
    val size = 160.dp
    Box(contentAlignment = Alignment.Center) {
        Canvas(
            modifier = Modifier.size(size)
        ) {
            val sweepAngle = viewModel.status.progressSweepAngle()
            // Circle radius
            val r = size.toPx() / 2
            // The width of Ring
            val stokeWidth = 12.dp.toPx()
            // Draw dial plate
            drawCircle(
                color = Color.LightGray,
                style = Stroke(
                    width = stokeWidth,
                    pathEffect = PathEffect.dashPathEffect(
                        intervals = floatArrayOf(1.dp.toPx(), 3.dp.toPx())
                    )
                )
            )
            // Draw ring
            drawArc(
                brush = Brush.sweepGradient(
                    0f to Color.Magenta,
                    0.5f to Color.Blue,
                    0.75f to Color.Green,
                    0.75f to Color.Transparent,
                    1f to Color.Magenta
                ),
                startAngle = -90f,
                sweepAngle = sweepAngle,
                useCenter = false,
                style = Stroke(
                    width = stokeWidth
                ),
                alpha = 0.5f
            )
            // Pointer
            val angle = (360 - sweepAngle) / 180 * Math.PI
            val pointTailLength = 8.dp.toPx()
            drawLine(
                color = Color.Red,
                start = Offset(r + pointTailLength * sin(angle).toFloat(), r + pointTailLength * cos(angle).toFloat()),
                end = Offset((r - r * sin(angle) - sin(angle) * stokeWidth / 2).toFloat(), (r - r * cos(angle) - cos(angle) * stokeWidth / 2).toFloat()),
                strokeWidth = 2.dp.toPx()
            )
            drawCircle(
                color = Color.Red,
                radius = 5.dp.toPx()
            )
            drawCircle(
                color = Color.White,
                radius = 3.dp.toPx()
            )
        }
    }
}


可以看到,我们先通过设置 drawCircle 函数中的 pathEffect 参数,绘制出底部灰色的刻度盘,其中,intervals 中的第一个 Float 参数指刻度的宽度,第二个 Float 参数指刻度的间距。

然后从 -90° 开始绘制圆环,通过 sweepAngle 的变化使得圆环从 360° 减到 0°,通过 brush 参数设置出渐变色。

中间的指针部分通过正弦函数、余弦函数的变换计算出起点和终点,绘制出一条直线和两个圆,组成指针的形状。

然后将此控件添加到 TimeLeftText 下方:

Column(
    modifier = Modifier.fillMaxSize(),
    verticalArrangement = Arrangement.Center,
    horizontalAlignment = Alignment.CenterHorizontally
) {
    TimeLeftText(viewModel)
    ProgressCircle(viewModel)
    EditText(viewModel)
    Row {
        StartButton(viewModel)
        StopButton(viewModel)
    }
}


运行效果如下:


总体看起来还不错,但指针的动画效果还不够丝滑。这是因为我们每隔 1s 才会去更新一次指针扫过的角度,当时间很短时,指针的变化看起来就很突兀。

解决方案也很简单,将更新指针的时间设置得短一些就可以了。前文说到,我们通过将 ValueAnimator 设置为在 totalTime 秒内从 totalTime 线性变化到 0 的方式设置出动画的间隔时间为 1s。想要加快更新的频率,我们可以将动画的初始值扩大一个倍数,总时长保持不变。这时,就不能再使用 timeLeft 来计算扫过的角度了,我们需要一个新的值来保存动画过程中的值。

class TimerViewModel : ViewModel() {
    //...

    /**
     * Temp value when anim is active
     */
    var animValue: Float by mutableStateOf(0.0f)
}

在 AnimatorController 中,加快动画更新的频率:

// Control how many times the pointer will be updated in a second
const val SPEED = 100

class AnimatorController(private val viewModel: TimerViewModel) {
    //...

    fun start() {
        if (viewModel.totalTime == 0L) return
        if (valueAnimator == null) {
            // Animator: totalTime -> 0
            valueAnimator = ValueAnimator.ofInt(viewModel.totalTime.toInt() * SPEED, 0)
            valueAnimator?.interpolator = LinearInterpolator()
            // Update timeLeft in ViewModel
            valueAnimator?.addUpdateListener {
                viewModel.animValue = (it.animatedValue as Int) / SPEED.toFloat()
                viewModel.timeLeft = (it.animatedValue as Int).toLong() / SPEED
            }
            valueAnimator?.addListener(object : AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: Animator?) {
                    super.onAnimationEnd(animation)
                    complete()
                }
            })
        } else {
            valueAnimator?.setIntValues(viewModel.totalTime.toInt() * SPEED, 0)
        }
        // (LinearInterpolator + duration) aim to set the interval as 1 second.
        valueAnimator?.duration = viewModel.totalTime * 1000L
        valueAnimator?.start()
        viewModel.status = StartedStatus(viewModel)
    }
}


在 StartedStatus 和 PausedStatus 中,使用这个 Float 值来更新 progressSweepAngle:

override fun progressSweepAngle() = viewModel.animValue / viewModel.totalTime * 360

这里我们将 SPEED 设置为 100,表示每秒钟更新 100 次指针的位置。修改后效果如下(实际效果比 gif 流畅很多,已经看不出卡顿):


CompletedText

最后,我们在倒计时结束时添加一个回调,一般的倒计时 App 会在结束时播放一段铃声,简单起见,我们在结束时展示一个 “Completed!” 文字即可。

同样地,为了避免状态判断,我们利用状态模式的优点,在 IStatus 中添加一个方法:

interface IStatus {
    //...

    /**
     * Completed string
     */
    fun completedString(): String
}

在 CompletedStatus 中,重载此方法为:

override fun completedString() = "Completed!"

在其他 Status 子类中,重载此方法为:

override fun completedString() = ""

创建 CompletedText 控件:

@Composable
private fun CompletedText(viewModel: TimerViewModel) {
    Text(
        text = viewModel.status.completedString(),
        color = MaterialTheme.colors.primary
    )
}


然后,将这个控件添加到 TimeLeftText 上方:

Column(
    modifier = Modifier.fillMaxSize(),
    verticalArrangement = Arrangement.Center,
    horizontalAlignment = Alignment.CenterHorizontally
) {
    CompletedText(viewModel)
    TimeLeftText(viewModel)
    ProgressCircle(viewModel)
    EditText(viewModel)
    Row {
        StartButton(viewModel)
        StopButton(viewModel)
    }
}


最后,在 EditText 更新时,如果状态是完成,则将其修改为尚未开始状态,因为当用户编辑 EditText 时,说明新一轮倒计时即将到来。

fun updateValue(text: String) {
    //...
    // After user clicks EditText, CompletedStatus turns to NotStartedStatus.
    if (status is CompletedStatus) status = NotStartedStatus(this)
}


最终运行效果如下:


这样,我们就使用 Compose 实现了一个完整的 Countdown Timer。源码已上传Github:
https://github.com/wkxjc/CountdownTimer

推荐阅读:
我参加了Jetpack Compose开发挑战赛
我的新书,《第一行代码 第3版》已出版!
Android 11新特性,Scoped Storage又有了新花样

欢迎关注我的公众号
学习技术或投稿


长按上图,识别图中二维码即可关注

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

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