2024年11月14日木曜日

M3 の LinearProgressIndicator の progress は lambda になっているが、使い方に注意しないと recomposition が走ることがある

M2 の LinearProgressIndicator の progress 引数は Float でしたが、M3 では () -> Float になっています。 androidx.compose.material.LinearProgressIndicator( progress = 0.5f, ) androidx.compose.material3.LinearProgressIndicator( progress = { 0.5f }, ) いずれも内部の実装は Canvas composable を使って描画しています。
そのため、progress を lambda にすることで Composition と Layout phase をスキップして Drawing phase だけやり直せばよくなり、その分パフォーマンスが良くなります。
https://developer.android.com/develop/ui/compose/phases

実際以下のコードを実行して Layout Inspector で recomposition の回数を見ると、M2 の方は recompositoin されていますが M3 の方は skip されています。 Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp), ) { var progress by remember { mutableFloatStateOf(0f) } androidx.compose.material3.Button( onClick = { progress = Random.nextFloat() }, ) { Text("update progress") } androidx.compose.material.LinearProgressIndicator( progress = progress, modifier = Modifier.fillMaxWidth(), ) androidx.compose.material3.LinearProgressIndicator( progress = { progress }, modifier = Modifier.fillMaxWidth(), ) }



M3 の LinearProgressIndicator を wrap するときは、wrap する component でも progress を lambda で取るように注意してください(より正確に言うと、lamda の中で state から読み出しを行うようにするということ)。そうしないと M3 の LinearProgressIndicator を使っていても recompose が走ります。 Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp), ) { var progress by remember { mutableFloatStateOf(0f) } androidx.compose.material3.Button( onClick = { progress = Random.nextFloat() }, ) { Text("update progress") } LinearProgressIndicatorM2(progress) LinearProgressIndicatorM3_Bad(progress) LinearProgressIndicatorM3_Good({ progress }) } @Composable private fun LinearProgressIndicatorM2(progress: Float) { androidx.compose.material.LinearProgressIndicator( progress = progress, modifier = Modifier.fillMaxWidth(), ) } @Composable private fun LinearProgressIndicatorM3_Bad(progress: Float) { androidx.compose.material3.LinearProgressIndicator( progress = { progress }, modifier = Modifier.fillMaxWidth(), ) } @Composable private fun LinearProgressIndicatorM3_Good(progress: () -> Float) { androidx.compose.material3.LinearProgressIndicator( progress = progress, modifier = Modifier.fillMaxWidth(), ) }



そうは言っても、階層のどこかで progress が読み出されていることもあるでしょう(Text Composable で progress の値を表示しているとか)。その場合は rememberUpdatedState を使うことで LinearProgressIndicator の recomposition を skip させることができます。 @Composable private fun Wrap(progress: Float, onUpdate: () -> Unit) { val updatedProgress by rememberUpdatedState(progress) Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp), ) { ... LinearProgressIndicatorM3_Good({ updatedProgress }) } }

2024年11月9日土曜日

LazyRow で snap させる

SnapLayoutInfoProvider での snap 位置の指定方法が変わっていた。

左端に snap する場合、以前は SnapLayoutInfoProvider( lazyListState = state, positionInLayout = { layoutSize, itemSize, beforeContentPadding, afterContentPadding, _-> 0 // 左端 }, ) だったが、SnapPosition という interface が用意され、 左端の場合は用意されている SnapPosition.Start を指定すれば良くなった。 Start の他に End と Center も用意されている。
任意の位置に snap したい場合は SnapPosition の実装を用意すればよい。 SnapLayoutInfoProvider( lazyListState = state, snapPosition = SnapPosition.Start, ) 全体のコードはこんな感じ。 @Composable fun LazyRowSnapSample() { val state = rememberLazyListState() val snappingLayout = remember(state) { SnapLayoutInfoProvider( lazyListState = state, snapPosition = SnapPosition.Start, ) } val flingBehavior = rememberSnapFlingBehavior(snappingLayout) LazyRow( modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, state = state, flingBehavior = flingBehavior, ) { items(200) { Box( contentAlignment = Alignment.Center, modifier = Modifier .height(400.dp) .width(300.dp) .padding(8.dp) .background(Color.LightGray), ) { Text(text = it.toString(), fontSize = 32.sp) } } } }

2024年10月23日水曜日

AutoScrollHorizontalPager を作る

  • (擬似的に)無限ループしたい
  • タップされているときは自動送りしない
private const val PAGE_COUNT = 10_000 private const val INITIAL_PAGE = PAGE_COUNT / 2 private fun Int.floorMod(other: Int): Int = when (other) { 0 -> this else -> this - this.floorDiv(other) * other } @Composable fun AutoScrollHorizontalPager( itemSize: Int, modifier: Modifier = Modifier, autoScrollDuration: Long = 2_000L, onPageChanged: ((page: Int) -> Unit)? = null, pageContent: @Composable PagerScope.(page: Int) -> Unit, ) { val pagerState = rememberPagerState(INITIAL_PAGE) { PAGE_COUNT } fun Int.toIndex(): Int = (this - INITIAL_PAGE).floorMod(itemSize) if (onPageChanged != null) { LaunchedEffect(onPageChanged) { snapshotFlow { pagerState.currentPage } .collect { page -> onPageChanged(page.toIndex()) } } } val dragged by pagerState.interactionSource.collectIsDraggedAsState() if (!dragged) { LaunchedEffect(Unit) { while (true) { delay(autoScrollDuration) val nextPage = pagerState.currentPage + 1 if (nextPage < PAGE_COUNT) { pagerState.animateScrollToPage(nextPage) } else { pagerState.scrollToPage(0) } } } } HorizontalPager( state = pagerState, modifier = modifier, ) { page -> pageContent(page.toIndex()) } }