在项目中有需要循环展示信息的功能,即文字的走马灯效果,在 Compose 中已经有 Modifier.basicMarquee,可以很方便的对 Text 内容应用效果,但是无法应用于普通列表场景。
在 LazyRow 中对于滑动状态的控制是 LazyListState 的职责,我们可以想到通过 lazyListState.scrollBy() 控制 LazyRow 的滑动。
于是有如下代码
@Composable
fun MarqueeRow(list: List<String>) {
val realCount = list.size
val virtualCount = Int.MAX_VALUE
val startIndex = virtualCount / 2
val listState = rememberLazyListState(startIndex)
LaunchedEffect(Unit) {
var lastTime = SystemClock.uptimeMillis()
val scrollSpeedPxPerSecond = 100f
while (true) {
val now = SystemClock.uptimeMillis()
val elapsed = now - lastTime
lastTime = now
val deltaPx = (scrollSpeedPxPerSecond * (elapsed / 1000f))
listState.animateScrollBy(deltaPx)
delay(16)
}
}
LazyRow(
Modifier,
listState,
contentPadding = PaddingValues(16.dp),
) {
items(virtualCount, { list[it % realCount] }) { idx ->
Surface(modifier = Modifier.padding(start = if (idx == 0) 0.dp else 10.dp), color = Color.Gray) {
Text(list[idx % realCount])
}
}
}
}
这个时候,已经初步实现了我们的需求,但是发现一卡一卡的,这是因为我们是在协程中通过 delay 时间片进行滑动的,对 Compose 来说,每次 delay 后的时间并不是等时连续的,在 animateScrollBy 中支持滑动的动画曲线,我们不使用 delay(16),选择使用 tween,并设置线性动画。listState.animateScrollBy(deltaPx, tween(16, easing = LinearEasing))。
发现效果要好很多,但是发现一个问题,当用户去触摸后,发现动画停止了,这是因为滑动动画是在协程中运行,用户的操作会取消已经执行的动画协程。因此需要找一个不需要协程的方法。LazyListState 的滑动是控制偏移,那么只要我们只要可以直接设置 offset 就可以绕过动画协程的取消,恰好 LazyListState 中就存在这样一个方法 dispatchRawDelta,但是这个方法只有一个 delat 参数,想让这方法一直执行,只能回到 delay 方案。
从结果看已经实现了想要的效果,但是通过 delay 的方案仍有一点点缺陷。可以通过比较2 中方式每次执行的时间间隔来观察,我们先简单实现一个时间间隔的采样折线图。
const val TICK_SIZE = 160
const val MAX_MS = 40
const val MIN_MS = 1
data class TickSample(
val dtMillis: Long,
)
@Composable
fun SampleCanvas(samples: List<TickSample>, modifier: Modifier) {
if (samples.size < 2) return
Canvas(modifier) {
val stepX = size.width / (samples.size - 1)
val path = Path()
samples.forEachIndexed { index, sample ->
val x = index * stepX
val clamped = sample.dtMillis.coerceIn(MIN_MS.toLong(), MAX_MS.toLong()).toFloat()
val normalized = (clamped - MIN_MS) / (MAX_MS - MIN_MS)
val y = size.height * (1f - normalized)
if (index == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
}
// 16ms 基准线
val baselineY = size.height * (1f - 16f / MAX_MS)
drawLine(
color = Color.Green,
start = Offset(0f, baselineY),
end = Offset(size.width, baselineY),
strokeWidth = 1.dp.toPx()
)
drawPath(
path = path,
color = Color.Red,
style = Stroke(2.dp.toPx())
)
}
}
在原来执行原始滑动地方往采样集合中添加数据。
...
sampleList.add(TickSample(elapsed))
if (sampleList.size > TICK_SIZE) {
sampleList.removeAt(0)
}
...
...
sampleList.add(TickSample(deltaMillis))
if (sampleList.size > TICK_SIZE) {
sampleList.removeAt(0)
}
...
从曲线中可以看到,delay 方案会有波动,withFrameMills 比较稳定。
完整代码
@Composable
fun MarqueeRowDelay(list: List<String>) {
val realCount = list.size
val virtualCount = Int.MAX_VALUE
val startIndex = virtualCount / 2
val listState = rememberLazyListState(startIndex)
val sampleList = remember { mutableStateListOf<TickSample>() }
LaunchedEffect(Unit) {
var lastTime = SystemClock.uptimeMillis()
val scrollSpeedPxPerSecond = 100f
while (true) {
val now = SystemClock.uptimeMillis()
val elapsed = now - lastTime
lastTime = now
val deltaPx = (scrollSpeedPxPerSecond * (elapsed / 1000f))
sampleList.add(TickSample(elapsed))
if (sampleList.size > TICK_SIZE) {
sampleList.removeAt(0)
}
listState.dispatchRawDelta(deltaPx)
delay(16)
}
}
Column {
LazyRow(
Modifier,
listState,
contentPadding = PaddingValues(16.dp),
) {
items(virtualCount, { list[it % realCount] }) { idx ->
OptionBox(modifier = Modifier.padding(start = if (idx == 0) 0.dp else 10.dp), color = Color.Gray) {
Text(list[idx % realCount])
}
}
}
SampleCanvas(
sampleList, Modifier
.background(Color.DarkGray)
.fillMaxWidth()
.height(100.dp)
)
}
}
@Composable
fun MarqueeRowFrame(list: List<String>) {
val realCount = list.size
val virtualCount = Int.MAX_VALUE
val startIndex = virtualCount / 2
val listState = rememberLazyListState(startIndex)
val sampleList = remember { mutableStateListOf<TickSample>() }
LaunchedEffect(Unit) {
val scrollSpeedPxPerSecond = 100f
var lastFrameTimeMillis = 0L
while (true) {
withFrameMillis { frameTimeMillis ->
if (lastFrameTimeMillis != 0L) {
val deltaMillis = frameTimeMillis - lastFrameTimeMillis
val deltaSeconds = deltaMillis / 1_000f
val deltaPx = scrollSpeedPxPerSecond * deltaSeconds
sampleList.add(TickSample(deltaMillis))
if (sampleList.size > TICK_SIZE) {
sampleList.removeAt(0)
}
listState.dispatchRawDelta(deltaPx)
}
lastFrameTimeMillis = frameTimeMillis
}
}
}
Column {
LazyRow(
Modifier,
listState,
contentPadding = PaddingValues(16.dp),
) {
items(virtualCount, { list[it % realCount] }) { idx ->
OptionBox(modifier = Modifier.padding(start = if (idx == 0) 0.dp else 10.dp), color = Color.Gray) {
Text(list[idx % realCount])
}
}
}
SampleCanvas(
sampleList, Modifier
.background(Color.DarkGray)
.fillMaxWidth()
.height(100.dp)
)
}
}
const val TICK_SIZE = 160
const val MAX_MS = 40
const val MIN_MS = 1
data class TickSample(
val dtMillis: Long,
)
@Composable
fun SampleCanvas(samples: List<TickSample>, modifier: Modifier) {
if (samples.size < 2) return
Canvas(modifier) {
val stepX = size.width / (samples.size - 1)
val path = Path()
samples.forEachIndexed { index, sample ->
val x = index * stepX
val clamped = sample.dtMillis.coerceIn(MIN_MS.toLong(), MAX_MS.toLong()).toFloat()
val normalized = (clamped - MIN_MS) / (MAX_MS - MIN_MS)
val y = size.height * (1f - normalized)
if (index == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
}
// 16ms 基准线
val baselineY = size.height * (1f - 16f / MAX_MS)
drawLine(
color = Color.Green,
start = Offset(0f, baselineY),
end = Offset(size.width, baselineY),
strokeWidth = 1.dp.toPx()
)
drawPath(
path = path,
color = Color.Red,
style = Stroke(2.dp.toPx())
)
}
}