Compose LazyRow 实现自动无限滑动效果

174 阅读3分钟

在项目中有需要循环展示信息的功能,即文字的走马灯效果,在 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])
      }
    }
  }
}

Dec-24-2025 17-54-40.gif

这个时候,已经初步实现了我们的需求,但是发现一卡一卡的,这是因为我们是在协程中通过 delay 时间片进行滑动的,对 Compose 来说,每次 delay 后的时间并不是等时连续的,在 animateScrollBy 中支持滑动的动画曲线,我们不使用 delay(16),选择使用 tween,并设置线性动画。listState.animateScrollBy(deltaPx, tween(16, easing = LinearEasing))

Dec-24-2025 18-10-50.gif

发现效果要好很多,但是发现一个问题,当用户去触摸后,发现动画停止了,这是因为滑动动画是在协程中运行,用户的操作会取消已经执行的动画协程。因此需要找一个不需要协程的方法。LazyListState 的滑动是控制偏移,那么只要我们只要可以直接设置 offset 就可以绕过动画协程的取消,恰好 LazyListState 中就存在这样一个方法 dispatchRawDelta,但是这个方法只有一个 delat 参数,想让这方法一直执行,只能回到 delay 方案。

Dec-24-2025 18-25-14.gif

从结果看已经实现了想要的效果,但是通过 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)
}
...

Dec-25-2025 11-07-17.gif

从曲线中可以看到,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())
    )
  }
}