Jetpack Compose中deriveStateOf与snapshotFlow协同使用时无法触发重组的问题
derivedStateOf and snapshotFlow Aren't Playing Nice in Your Jetpack Compose Code Great question! Let's break down exactly why your second button stops showing when you switch to derivedStateOf, and how to fix this issue.
What's Going Wrong?
Let's start with the core difference between your working and broken code:
Original Code (Working)
Your initial implementation used remember(listState.firstVisibleItemIndex) to create a mutableStateOf for showButtonDerive:
val showButtonDerive by remember(listState.firstVisibleItemIndex) { mutableStateOf(listState.firstVisibleItemIndex > 0) }
This setup forces the entire TestSnapshotFlow composable to recompose every time firstVisibleItemIndex changes—even if the result of listState.firstVisibleItemIndex > 0 stays the same. This over-recomposition ensures that whenever showButtonSnapshot is updated in your snapshotFlow collector, the next recomposition picks up the new value and updates the second button's visibility.
Broken Code (Using derivedStateOf)
When you switched to derivedStateOf:
val showButtonDerive by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
Compose applies its fine-grained recomposition optimizations. derivedStateOf only triggers recomposition when its computed result changes (e.g., when firstVisibleItemIndex goes from 0 to 1, flipping showButtonDerive from false to true). Once showButtonDerive is true, further scrolls (like moving to index > 2) don't trigger a full recomposition of TestSnapshotFlow.
Even though your snapshotFlow correctly updates showButtonSnapshot to true, the second AnimatedVisibility doesn't update because:
- The full
TestSnapshotFlowcomposable isn't recomposing (sinceshowButtonDerivehasn't changed) - The second
AnimatedVisibilityis nested directly inTestSnapshotFlow, and Compose's optimization doesn't trigger a targeted recomposition for it alone in this setup.
Fixes to Try
Here are three straightforward solutions to get your code working as expected:
1. Replace snapshotFlow with Another derivedStateOf
Since you're already using derivedStateOf for the first button, extend this pattern to the second button. This eliminates the need for snapshotFlow and mutableStateOf entirely, letting Compose handle all recomposition logic efficiently:
@ExperimentalAnimationApi @Composable fun TestSnapshotFlow() { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { val listState = rememberLazyListState() LazyColumn(state = listState) { items(1000) { index -> Text(text = "Item: $index") } } // Use derivedStateOf for both buttons val showButtonDerive by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } val showButtonSnapshot by remember { derivedStateOf { listState.firstVisibleItemIndex > 2 } } Log.d("Track", "Recompose") Column { AnimatedVisibility(showButtonDerive) { Button({}) { Text("Row 1 hiding") } } AnimatedVisibility(showButtonSnapshot) { Button({}) { Text("Row 1 and 2 hiding") } } } } }
2. Force Recomposition on showButtonSnapshot Changes
If you want to keep using snapshotFlow, you can force TestSnapshotFlow to recompose whenever showButtonSnapshot changes by adding a dummy read of the state at the top level of the composable:
@ExperimentalAnimationApi @Composable fun TestSnapshotFlow() { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { val listState = rememberLazyListState() LazyColumn(state = listState) { items(1000) { index -> Text(text = "Item: $index") } } var showButtonSnapshot by remember { mutableStateOf(false) } // Dummy read to trigger full recomposition when showButtonSnapshot changes val _ = showButtonSnapshot val showButtonDerive by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } Log.d("Track", "Recompose") Column { AnimatedVisibility(showButtonDerive) { Button({}) { Text("Row 1 hiding") } } AnimatedVisibility(showButtonSnapshot) { Button({}) { Text("Row 1 and 2 hiding") } } } LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex } .map { index -> index > 2 } .distinctUntilChanged() .collect { Log.d("Track", "B $it") showButtonSnapshot = it } } } }
3. Extract the Second Button to a Separate Composable
Compose's targeted recomposition works best when you split your UI into smaller, focused composables. Extract the second button into its own composable, which directly reads showButtonSnapshot:
@ExperimentalAnimationApi @Composable fun TestSnapshotFlow() { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { val listState = rememberLazyListState() LazyColumn(state = listState) { items(1000) { index -> Text(text = "Item: $index") } } var showButtonSnapshot by remember { mutableStateOf(false) } val showButtonDerive by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } Log.d("Track", "Recompose") Column { AnimatedVisibility(showButtonDerive) { Button({}) { Text("Row 1 hiding") } } // Use the separate composable SecondVisibilityButton(show = showButtonSnapshot) } LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex } .map { index -> index > 2 } .distinctUntilChanged() .collect { Log.d("Track", "B $it") showButtonSnapshot = it } } } } @ExperimentalAnimationApi @Composable private fun SecondVisibilityButton(show: Boolean) { AnimatedVisibility(show) { Button({}) { Text("Row 1 and 2 hiding") } } }
This way, when showButtonSnapshot changes, only SecondVisibilityButton recomposes—no need for full composable recomposition, and the button will show up as expected.
Key Takeaway
derivedStateOf is a powerful tool to reduce unnecessary recompositions, but it requires you to think more carefully about how state changes propagate through your UI. The original remember with a key was causing over-recomposition, which accidentally masked the issue with snapshotFlow and mutableStateOf. By aligning your state handling with Compose's recomposition model (either using derivedStateOf for all derived states or splitting composables), you'll get both correct behavior and optimal performance.
内容的提问来源于stack exchange,提问作者Elye

