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 01:53:06 +08:00
parent 4f202f8c10
commit 628813c3f6
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.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
@@ -83,7 +84,7 @@ fun DictionaryListItem(
onToggle: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
canDelete: Boolean = true
isDeleteBlocked: () -> Boolean = { false }
) {
val haptic = LocalHapticFeedback.current
val density = LocalDensity.current
@@ -95,8 +96,9 @@ fun DictionaryListItem(
val offsetAnim = remember { Animatable(0f) }
var rawOffset by remember { mutableFloatStateOf(0f) }
val isDeleteRevealed by remember(canDelete) {
derivedStateOf { canDelete && rawOffset <= -swipeThresholdPx }
val isDeleteBlockedCurrent by rememberUpdatedState(isDeleteBlocked)
val isDeleteRevealed by remember {
derivedStateOf { !isDeleteBlockedCurrent() && rawOffset <= -swipeThresholdPx }
}
LaunchedEffect(isDeleteRevealed) {
@@ -105,10 +107,13 @@ fun DictionaryListItem(
}
}
LaunchedEffect(canDelete) {
if (!canDelete) {
LaunchedEffect(Unit) {
snapshotFlow { isDeleteBlockedCurrent() }
.collect { blocked ->
if (blocked) {
rawOffset = 0f
offsetAnim.snapTo(0f)
scope.launch { offsetAnim.snapTo(0f) }
}
}
}
@@ -142,13 +147,14 @@ fun DictionaryListItem(
.height(DictionaryItemHeight)
.background(MaterialTheme.colorScheme.surfaceVariant)
.graphicsLayer { translationX = offsetAnim.value }
.pointerInput(canDelete, maxSwipePx, swipeThresholdPx) {
if (!canDelete) return@pointerInput
.pointerInput(maxSwipePx, swipeThresholdPx) {
detectHorizontalDragGestures(
onDragStart = {
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
},
onDragEnd = {
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
rawOffset = target
scope.launch {
@@ -159,6 +165,7 @@ fun DictionaryListItem(
}
},
onDragCancel = {
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
val target = if (rawOffset <= -swipeThresholdPx) -maxSwipePx else 0f
rawOffset = target
scope.launch {
@@ -169,6 +176,7 @@ fun DictionaryListItem(
}
},
onHorizontalDrag = { _, dragAmount ->
if (isDeleteBlockedCurrent()) return@detectHorizontalDragGestures
val newOffset = (rawOffset + dragAmount).coerceIn(-maxSwipePx, 0f)
rawOffset = newOffset
scope.launch { offsetAnim.snapTo(newOffset) }
@@ -13,6 +13,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -75,6 +76,13 @@ fun DictionaryManagement(
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
}
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(
modifier = modifier
@@ -125,13 +133,13 @@ fun DictionaryManagement(
// Disable accessibility on background content during import to improve performance.
// The progress dialog remains accessible for cancellation.
val backgroundModifier = if (isInProgress) {
val backgroundModifier = if (isBlockingMutations) {
Modifier.semantics(mergeDescendants = true) { }
} else {
Modifier
}
if (dictionaries.isEmpty() && !isInProgress) {
if (dictionaries.isEmpty() && !isBlockingMutations) {
Column(
modifier = backgroundModifier
.fillMaxWidth()
@@ -153,7 +161,7 @@ fun DictionaryManagement(
)
}
} else {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) {
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isBlockingMutations && hasEnabledSources) {
DictionaryUpdateCard(
onStartDownload = onStartDownload,
modifier = backgroundModifier
@@ -167,10 +175,10 @@ fun DictionaryManagement(
modifier = Modifier
.fillMaxWidth()
.then(backgroundModifier),
canDelete = !isInProgress
isDeleteBlocked = isDeleteBlocked
)
if (!isInProgress) {
if (!isBlockingMutations) {
DictionaryActionButtons(
onAddSource = {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -248,7 +256,7 @@ private fun DictionaryListSection(
onToggleDictionary: (String) -> Unit,
onDeleteDictionary: (Dictionary) -> Unit,
modifier: Modifier = Modifier,
canDelete: Boolean = true
isDeleteBlocked: () -> Boolean = { false }
) {
Column(
modifier = modifier,
@@ -259,7 +267,7 @@ private fun DictionaryListSection(
dictionary = dictionary,
onToggle = { onToggleDictionary(dictionary.path) },
onDelete = { onDeleteDictionary(dictionary) },
canDelete = canDelete
isDeleteBlocked = isDeleteBlocked
)
}
}