如何使用 Jetpack Compose 动画创建定时 Instagram 故事加载栏?

How to create timed Instagram story loading bar using Jetpack Compose animation?

我想创建一个与 Instagram 故事加载栏非常相似的可组合组件,持续时间为 10 秒。

我有一个想法如何去做,但我不知道如何执行。我正在考虑使用 BOX 添加一个静态栏(灰色),然后添加另一个栏(白色),它在 10 秒内从 0 动画到可组合宽度的最终宽度。

你知道我如何实现这个组件吗?

您可以为 LinearProgressIndicator 的进度设置动画。 类似于:

var enabled by remember { mutableStateOf(false) }
val progress: Float by animateFloatAsState(
    if (enabled) 1f else 0.0f,
    animationSpec = tween(
        durationMillis = 10000,
        delayMillis = 40,
        easing = LinearOutSlowInEasing
    )
)

LinearProgressIndicator(
    color = White,
    backgroundColor = LightGray,
    progress = progress,
    modifier = Modifier.width(100.dp)
)

只需要设置enabled=true就可以开始播放动画(只是一个例子)

您可以使用此 Composable 来创建分段进度条

private const val BackgroundOpacity = 0.25f
private const val NumberOfSegments = 8
private val StrokeWidth = 4.dp
private val SegmentGap = 8.dp

@Composable
fun SegmentedProgressIndicator(
    /*@FloatRange(from = 0.0, to = 1.0)*/
    progress: Float,
    modifier: Modifier = Modifier,
    color: Color = MaterialTheme.colors.primary,
    backgroundColor: Color = color.copy(alpha = BackgroundOpacity),
    strokeWidth: Dp = StrokeWidth,
    numberOfSegments: Int = NumberOfSegments,
    segmentGap: Dp = SegmentGap
) {
    val gap: Float
    val stroke: Float
    with(LocalDensity.current) {
        gap = segmentGap.toPx()
        stroke = strokeWidth.toPx()
    }
    Canvas(
        modifier
            .progressSemantics(progress)
            .fillMaxWidth()
            .height(strokeWidth)
            .focusable()
    ) {
        drawSegments(1f, backgroundColor, stroke, numberOfSegments, gap)
        drawSegments(progress, color, stroke, numberOfSegments, gap)
    }
}

private fun DrawScope.drawSegments(
    progress: Float,
    color: Color,
    strokeWidth: Float,
    segments: Int,
    segmentGap: Float,
) {
    val width = size.width
    val start = 0f
    val gaps = (segments - 1) * segmentGap
    val segmentWidth = (width - gaps) / segments
    val barsWidth = segmentWidth * segments
    val end = barsWidth * progress + (progress * segments).toInt()* segmentGap

    repeat(segments) { index ->
        val offset = index * (segmentWidth + segmentGap)
        if (offset < end) {
            val barEnd = (offset + segmentWidth).coerceAtMost(end)
            drawLine(
                color,
                Offset(start + offset, 0f),
                Offset(barEnd, 0f),
                strokeWidth
            )
        }
    }
}

你这样用

var running by remember { mutableStateOf(false) }
val progress: Float by animateFloatAsState(
    if (running) 1f else 0f,
    animationSpec = tween(
        durationMillis = 10_000,
        easing = LinearEasing
    )
)
Surface(color = MaterialTheme.colors.background) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        SegmentedProgressIndicator(
            progress = progress,
            modifier = Modifier
                .padding(top = 64.dp, start = 32.dp, end = 32.dp)
                .fillMaxWidth(),
        )

        Button(
            onClick = { running = !running },
            modifier = Modifier.padding(top = 32.dp)
        ) {
            Text(
                text = if (running) "Reverse Animation" else "Start Animation"
            )
        }
    }
}

这是结果

试试这个解决方案,它应该有效:

 var progress by remember {mutableStateOf(0.0f)}
var enabled by remember { mutableStateOf(true) }
LaunchedEffect(key1 = progress, key2 = enabled) {
    if(progress<1 && enabled) {
        delay(100L)
        progress += 0.01F
    }
}
LinearProgressIndicator(
    color = White,
    backgroundColor = LightGray,
    progress = progress,
    modifier = Modifier.width(100.dp)
)

这是一个完整的示例解决方案,您可以查看并尝试扩展和重构。代码是self-explanatory,你会看到它是怎么回事。

故事可组合:

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun Story(story: Stories) {
    AnimatedContent(story) {
        when (story) {
            Stories.ONE -> {
                StoryContent("1")
            }
            Stories.TWO -> {
                StoryContent("2")
            }
            Stories.THREE -> {
                StoryContent("3")
            }
        }
    }
}

@Composable
private fun StoryContent(content: String) {
    Text("Story $content")
}

故事进度指示器:

@Composable
fun StoryProgressIndicator(running: Boolean, modifier: Modifier = Modifier, onTenSecondsOnThisStory: () -> Unit) {
    val progress: Float by animateFloatAsState(
        if (running) 1f else 0f,
        animationSpec = tween(
            durationMillis = if (running) 10_000 else 0,
            easing = LinearEasing
        )
    )
    if (progress == 1f) {
        onTenSecondsOnThisStory()
    }
    LinearProgressIndicator(
        progress, modifier
    )
}

以及包含故事导航和播放动画的屏幕。

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun InstagramLikeStories() {
    var screenWidth by remember { mutableStateOf(1) }
    var currentStory by remember { mutableStateOf(Stories.ONE) }
    var currentStoryPointer by remember { mutableStateOf(0) }
    var runningStoryOne by remember { mutableStateOf(false) }
    var runningStoryTwo by remember { mutableStateOf(false) }
    var runningStoryThree by remember { mutableStateOf(false) }

    val runStoryOne = { runningStoryOne = true; runningStoryTwo = false; runningStoryThree = false }
    val runStoryTwo = { runningStoryOne = false; runningStoryTwo = true; runningStoryThree = false }
    val runStoryThree = { runningStoryOne = false; runningStoryTwo = false; runningStoryThree = true }

    val stories = Stories.values()

    LaunchedEffect(Unit) { runStoryOne() }

    Column(
        Modifier.fillMaxSize().onGloballyPositioned {
            screenWidth = it.size.width
        }.pointerInput(Unit) {
            detectTapGestures(onTap = {
                if ((it.x / screenWidth) * 100 > 50) {
                    if (currentStoryPointer == stories.size - 1) {
                        currentStoryPointer = 0
                    } else {
                        currentStoryPointer++
                    }
                    currentStory = stories[currentStoryPointer]
                } else {
                    if (currentStoryPointer != 0) {
                        currentStoryPointer--
                        currentStory = stories[currentStoryPointer]
                    }
                }
                runStoryIndicator(currentStory, runStoryOne, runStoryTwo, runStoryThree)
            })
        }
    ) {

        Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
            StoryProgressIndicator(runningStoryOne, Modifier.weight(1f), onTenSecondsOnThisStory = {
                runStoryIndicator(currentStory, runStoryOne, runStoryTwo, runStoryThree)
                currentStoryPointer = 1
                currentStory = stories[currentStoryPointer]
            })
            Spacer(Modifier.weight(0.1f))
            StoryProgressIndicator(runningStoryTwo, Modifier.weight(1f), onTenSecondsOnThisStory = {
                runStoryIndicator(currentStory, runStoryOne, runStoryTwo, runStoryThree)
                currentStoryPointer = 2
                currentStory = stories[currentStoryPointer]
            })
            Spacer(Modifier.weight(0.1f))
            StoryProgressIndicator(runningStoryThree, Modifier.weight(1f), onTenSecondsOnThisStory = {
                runStoryIndicator(currentStory, runStoryOne, runStoryTwo, runStoryThree)
                // go to first one
                currentStoryPointer = 0
                currentStory = stories[currentStoryPointer]
            })
        }
    }
    Story(currentStory)
    Row {
        Button(onClick = {
        }) {
           Text("button")
        }
    }
}

private fun runStoryIndicator(
    currentStory: Stories,
    runStoryOne: () -> Unit,
    runStoryTwo: () -> Unit,
    runStoryThree: () -> Unit
) {
    when (currentStory) {
        Stories.ONE -> runStoryOne()
        Stories.TWO -> runStoryTwo()
        Stories.THREE -> runStoryThree()
    }
}

enum class Stories {
    ONE, TWO, THREE
}