其他
Jetpack Compose第二周挑战赛,做一个倒计时器
https://blog.csdn.net/AlpinistWang
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()
}
}
我们熟悉的 handler.postDelayed 的方式
在协程中 repeat + delay 的方式
使用 ValueAnimator 的方式
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
}
}
//...
var animatorController = AnimatorController(this)
}
尚未开始
已经开始
暂停
完成
/**
* 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
}
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 隐藏。
override fun startButtonDisplayString() = "Start"
override fun clickStartButton() = viewModel.animatorController.start()
override fun stopButtonEnabled() = false
override fun clickStopButton() {}
override fun showEditText() = true
}
override fun startButtonDisplayString() = "Pause"
override fun clickStartButton() = viewModel.animatorController.pause()
override fun stopButtonEnabled() = true
override fun clickStopButton() = viewModel.animatorController.stop()
override fun showEditText() = false
}
override fun startButtonDisplayString() = "Resume"
override fun clickStartButton() = viewModel.animatorController.resume()
override fun stopButtonEnabled() = true
override fun clickStopButton() = viewModel.animatorController.stop()
override fun showEditText() = false
}
override fun startButtonDisplayString() = "Start"
override fun clickStartButton() = viewModel.animatorController.start()
override fun stopButtonEnabled() = false
override fun clickStopButton() {}
override fun showEditText() = true
}
//...
var status: IStatus by mutableStateOf(NotStartedStatus(this))
}
//...
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)
}
}
private fun TimeLeftText(viewModel: TimerViewModel) {
Text(
text = TimeFormatUtils.formatTime(viewModel.timeLeft),
modifier = Modifier.padding(16.dp)
)
}
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)
}
}
@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))
}
}
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)
)
}
}
}
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())
}
}
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")
}
}
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)
}
}
}
}
//...
/**
* Sweep angle of progress circle
*/
fun progressSweepAngle(): Float
}
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()
)
}
}
}
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
TimeLeftText(viewModel)
ProgressCircle(viewModel)
EditText(viewModel)
Row {
StartButton(viewModel)
StopButton(viewModel)
}
}
//...
/**
* Temp value when anim is active
*/
var animValue: Float by mutableStateOf(0.0f)
}
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)
}
}
//...
/**
* Completed string
*/
fun completedString(): String
}
private fun CompletedText(viewModel: TimerViewModel) {
Text(
text = viewModel.status.completedString(),
color = MaterialTheme.colors.primary
)
}
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CompletedText(viewModel)
TimeLeftText(viewModel)
ProgressCircle(viewModel)
EditText(viewModel)
Row {
StartButton(viewModel)
StopButton(viewModel)
}
}
//...
// After user clicks EditText, CompletedStatus turns to NotStartedStatus.
if (status is CompletedStatus) status = NotStartedStatus(this)
}
https://github.com/wkxjc/CountdownTimer