refactor: isolate dictionary rows from mutation-block recomposition

Delete-block flag becomes a () -> Boolean provider read at state/gesture
time, so flipping it no longer recomposes the whole Dictionaries block.
This commit is contained in:
2026-09-04 14:59:59 +08:00
parent 33cc386015
commit 84800cf2b0
2 changed files with 33 additions and 17 deletions
@@ -28,6 +28,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
@@ -83,7 +84,7 @@ fun DictionaryListItem(
onToggle: () -> Unit, onToggle: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canDelete: Boolean = true isDeleteBlocked: () -> Boolean = { false }
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val density = LocalDensity.current val density = LocalDensity.current
@@ -95,8 +96,9 @@ fun DictionaryListItem(
val offsetAnim = remember { Animatable(0f) } val offsetAnim = remember { Animatable(0f) }
var rawOffset by remember { mutableFloatStateOf(0f) } var rawOffset by remember { mutableFloatStateOf(0f) }
val isDeleteRevealed by remember(canDelete) { val isDeleteBlockedCurrent by rememberUpdatedState(isDeleteBlocked)
derivedStateOf { canDelete && rawOffset <= -swipeThresholdPx } val isDeleteRevealed by remember {
derivedStateOf { !isDeleteBlockedCurrent() && rawOffset <= -swipeThresholdPx }
} }
LaunchedEffect(isDeleteRevealed) { LaunchedEffect(isDeleteRevealed) {
@@ -105,11 +107,14 @@ fun DictionaryListItem(
} }
} }
LaunchedEffect(canDelete) { LaunchedEffect(Unit) {
if (!canDelete) { snapshotFlow { isDeleteBlockedCurrent() }
rawOffset = 0f .collect { blocked ->
offsetAnim.snapTo(0f) if (blocked) {
} rawOffset = 0f
scope.launch { offsetAnim.snapTo(0f) }
}
}
} }
val currentOnDelete by rememberUpdatedState(onDelete) val currentOnDelete by rememberUpdatedState(onDelete)
@@ -142,13 +147,14 @@ fun DictionaryListItem(
.height(DictionaryItemHeight) .height(DictionaryItemHeight)
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.graphicsLayer { translationX = offsetAnim.value } .graphicsLayer { translationX = offsetAnim.value }
.pointerInput(canDelete, maxSwipePx, swipeThresholdPx) { .pointerInput(maxSwipePx, swipeThresholdPx) {
if (!canDelete) return@pointerInput
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragStart = { onDragStart = {
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}, },
onDragEnd = { onDragEnd = {
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
rawOffset = target rawOffset = target
scope.launch { scope.launch {
@@ -159,6 +165,7 @@ fun DictionaryListItem(
} }
}, },
onDragCancel = { onDragCancel = {
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
rawOffset = target rawOffset = target
scope.launch { scope.launch {
@@ -169,6 +176,7 @@ fun DictionaryListItem(
} }
}, },
onHorizontalDrag = { _, dragAmount -> onHorizontalDrag = { _, dragAmount ->
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f) val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f)
rawOffset = newOffset rawOffset = newOffset
scope.launch { offsetAnim.snapTo(newOffset) } scope.launch { offsetAnim.snapTo(newOffset) }
@@ -13,6 +13,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -75,6 +76,13 @@ fun DictionaryManagement(
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting } derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
} }
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } } val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
val isBlockingMutations = isInProgress
// Invariant: the mutation-block flag is read only at state-read/gesture time (derivedStateOf,
// snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read
// (e.g. Modifier.alpha(if (isDeleteBlocked()) ...)) would invalidate every row on each flip.
val isMutationsBlockedState = rememberUpdatedState(isBlockingMutations)
val isDeleteBlocked = remember { { isMutationsBlockedState.value } }
SectionCard( SectionCard(
modifier = modifier modifier = modifier
@@ -125,13 +133,13 @@ fun DictionaryManagement(
// Disable accessibility on background content during import to improve performance. // Disable accessibility on background content during import to improve performance.
// The progress dialog remains accessible for cancellation. // The progress dialog remains accessible for cancellation.
val backgroundModifier = if (isInProgress) { val backgroundModifier = if (isBlockingMutations) {
Modifier.semantics(mergeDescendants = true) { } Modifier.semantics(mergeDescendants = true) { }
} else { } else {
Modifier Modifier
} }
if (dictionaries.isEmpty() && !isInProgress) { if (dictionaries.isEmpty() && !isBlockingMutations) {
Column( Column(
modifier = backgroundModifier modifier = backgroundModifier
.fillMaxWidth() .fillMaxWidth()
@@ -153,7 +161,7 @@ fun DictionaryManagement(
) )
} }
} else { } else {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) { if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isBlockingMutations && hasEnabledSources) {
DictionaryUpdateCard( DictionaryUpdateCard(
onStartDownload = onStartDownload, onStartDownload = onStartDownload,
modifier = backgroundModifier modifier = backgroundModifier
@@ -167,10 +175,10 @@ fun DictionaryManagement(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(backgroundModifier), .then(backgroundModifier),
canDelete = !isInProgress isDeleteBlocked = isDeleteBlocked
) )
if (!isInProgress) { if (!isBlockingMutations) {
DictionaryActionButtons( DictionaryActionButtons(
onAddSource = { onAddSource = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -248,7 +256,7 @@ private fun DictionaryListSection(
onToggleDictionary: (String) -> Unit, onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit, onDeleteDictionary: (Dictionary) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canDelete: Boolean = true isDeleteBlocked: () -> Boolean = { false }
) { ) {
Column( Column(
modifier = modifier, modifier = modifier,
@@ -259,7 +267,7 @@ private fun DictionaryListSection(
dictionary = dictionary, dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) }, onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) }, onDelete = { onDeleteDictionary(dictionary) },
canDelete = canDelete isDeleteBlocked = isDeleteBlocked
) )
} }
} }