Skip to content

Commit 00ecef8

Browse files
committed
General: Stabilize multi-select with a per-id SelectionState holder
Replace rememberSelection<T>() (MutableState<Set<T>>) with a @stable SelectionState<T> holder exposing per-id membership via @composable isSelected(id) backed by derivedStateOf, plus isActive/count as derivedStateOf. Toggling one row now recomposes only that row instead of the whole visible window: previously every visible item subscribed to the single Set state and re-ran its leaf work (thumbnails, file-size/date formatting, plurals) on every toggle. Migrated all 13 call sites across corpsefinder, systemcleaner, deduplicator, analyzer, squeezer, appcontrol, appcleaner, exclusion and swiper. Screen-scope aggregate reads (Squeezer savings, Analyzer selected-items/none-inaccessible) moved into their top-bar composables so they no longer re-invalidate the list. Container composables (CorpseContent, AppJunkPage, FilterContentPage, ClusterContent) now take the holder. AppControl pendingExportIds migrated too, so the old helper could be deleted. Deduplicator details: preserved the keep-one delete cap on toggle/long-press/select-all, and additionally re-apply it after the prune so a cluster shrinking under a held selection (or a restored selection against a smaller cluster) can no longer let a delete take the whole cluster. Adds SelectionState unit tests plus a Robolectric recomposition-isolation test proving a steady-active toggle recomposes only the toggled row.
1 parent 246aa94 commit 00ecef8

21 files changed

Lines changed: 748 additions & 444 deletions

File tree

app-common-exclusion/src/main/java/eu/darken/sdmse/exclusion/ui/list/ExclusionListScreen.kt

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import eu.darken.sdmse.common.compose.layout.SdmTooltipIconButton
5858
import eu.darken.sdmse.common.compose.layout.SdmTopAppBar
5959
import eu.darken.sdmse.common.compose.preview.Preview2
6060
import eu.darken.sdmse.common.compose.preview.PreviewWrapper
61-
import eu.darken.sdmse.common.compose.selection.rememberSelection
61+
import eu.darken.sdmse.common.compose.selection.rememberSelectionState
6262
import eu.darken.sdmse.common.error.ErrorEventHandler
6363
import eu.darken.sdmse.common.exclusion.R
6464
import eu.darken.sdmse.common.navigation.NavigationEventHandler
@@ -196,19 +196,20 @@ internal fun ExclusionListScreen(
196196
// back via the Undo snackbar or "Reset defaults". So "Select all" covers every row.
197197
val selectableIds = currentIds
198198

199-
var selection by rememberSelection<ExclusionId>()
199+
val selection = rememberSelectionState<ExclusionId>()
200200
// Prune stale IDs when the underlying list changes.
201-
LaunchedEffect(currentIds) { selection = selection intersect currentIds }
201+
LaunchedEffect(currentIds) { selection.retainAll(currentIds) }
202+
val selectionActive = selection.isActive
202203

203204
var overflowExpanded by remember { mutableStateOf(false) }
204205
var infoOpen by rememberSaveable { mutableStateOf(false) }
205206

206-
BackHandler(enabled = selection.isNotEmpty()) { selection = emptySet() }
207+
BackHandler(enabled = selectionActive) { selection.clear() }
207208

208209
SdmScaffold(
209210
snackbarHost = { SnackbarHost(snackbarHostState) },
210211
topBar = {
211-
if (selection.isEmpty()) {
212+
if (!selectionActive) {
212213
SdmTopAppBar(
213214
title = stringResource(R.string.exclusion_manager_title),
214215
onNavigateUp = onNavigateUp,
@@ -264,31 +265,33 @@ internal fun ExclusionListScreen(
264265
)
265266
} else {
266267
SdmSelectionTopAppBar(
267-
selectedCount = selection.size,
268-
onClearSelection = { selection = emptySet() },
268+
selectedCount = selection.count,
269+
onClearSelection = { selection.clear() },
269270
actions = {
270271
SdmTooltipIconButton(
271272
icon = Icons.TwoTone.FileUpload,
272273
label = stringResource(R.string.exclusion_export_action),
273274
onClick = {
274-
onExportSelected(selection.toSet())
275-
selection = emptySet()
275+
val ids = selection.selected
276+
selection.clear()
277+
onExportSelected(ids)
276278
},
277279
)
278280
SdmDeleteAction(onClick = {
279-
onRemoveSelected(selection.toSet())
280-
selection = emptySet()
281+
val ids = selection.selected
282+
selection.clear()
283+
onRemoveSelected(ids)
281284
})
282285
SdmSelectAllAction(
283-
visible = selection.size < selectableIds.size,
284-
onClick = { selection = selectableIds },
286+
visible = selection.count < selectableIds.size,
287+
onClick = { selection.setSelection(selectableIds) },
285288
)
286289
},
287290
)
288291
}
289292
},
290293
floatingActionButton = {
291-
if (selection.isEmpty()) {
294+
if (!selectionActive) {
292295
FloatingActionButton(onClick = onAddExclusion) {
293296
Icon(Icons.TwoTone.Add, contentDescription = stringResource(R.string.exclusion_create_action))
294297
}
@@ -309,16 +312,16 @@ internal fun ExclusionListScreen(
309312

310313
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
311314
items(rows, key = { it.stableId }) { row ->
312-
val isSelected = selection.contains(row.stableId)
315+
val isSelected = selection.isSelected(row.stableId)
313316
val onRowTap = {
314-
if (selection.isNotEmpty()) {
315-
selection = selection.toggle(row.stableId)
317+
if (selection.isActive) {
318+
selection.toggle(row.stableId)
316319
} else {
317320
onRowClick(row)
318321
}
319322
}
320323
val onRowLongPress = {
321-
selection = selection.toggle(row.stableId)
324+
selection.toggle(row.stableId)
322325
}
323326
when (row) {
324327
is ExclusionListViewModel.Row.Pkg -> PkgExclusionRow(
@@ -406,9 +409,6 @@ private fun ExclusionEmptyState(
406409
)
407410
}
408411

409-
private fun Set<ExclusionId>.toggle(id: ExclusionId): Set<ExclusionId> =
410-
if (contains(id)) this - id else this + id
411-
412412
@Preview2
413413
@Composable
414414
private fun ExclusionListScreenEmptyPreview() {

app-common-ui/src/main/java/eu/darken/sdmse/common/compose/selection/RememberSelection.kt

Lines changed: 0 additions & 26 deletions
This file was deleted.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package eu.darken.sdmse.common.compose.selection
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.Stable
5+
import androidx.compose.runtime.derivedStateOf
6+
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.mutableStateOf
8+
import androidx.compose.runtime.remember
9+
import androidx.compose.runtime.saveable.listSaver
10+
import androidx.compose.runtime.saveable.rememberSaveable
11+
import androidx.compose.runtime.setValue
12+
13+
/**
14+
* Multi-selection state for tool list/detail screens, designed so that toggling a single row only
15+
* recomposes that row instead of the whole visible window.
16+
*
17+
* The previous helper handed out a raw `MutableState<Set<T>>`, and screens read `selection.contains(id)`
18+
* and `selection.isNotEmpty()` directly inside their `items {}` lambda. Every visible item then subscribed
19+
* to the one set state, so a single toggle invalidated every item and re-ran its expensive leaf work
20+
* (thumbnails, file-size/date formatting, plurals). This holder replaces that pattern:
21+
*
22+
* - [isSelected] exposes per-id membership as a `derivedStateOf`, so only rows whose membership actually
23+
* flips recompose. This isolation holds while selection is already active; entering selection mode
24+
* (first item) or clearing the last item flips [isActive] and legitimately recomposes every row that
25+
* reacts to selection mode.
26+
* - [isActive] and [count] are `derivedStateOf`, so a container or top bar reading them only recomposes on
27+
* the empty<->non-empty transition / count change, not on every toggle.
28+
*
29+
* Backed by [rememberSelectionState] / [rememberSaveable] so it survives configuration changes and process
30+
* death (every selection id type is Bundle-saveable, as with the prior helper).
31+
*/
32+
@Stable
33+
class SelectionState<T : Any>(initial: Set<T> = emptySet()) {
34+
35+
// Defensive copy: never alias a caller's (possibly mutable) set into snapshot state.
36+
private var _selected by mutableStateOf(initial.toSet())
37+
38+
/**
39+
* Snapshot of the current selection. Reading this subscribes to ANY selection change, so use it at
40+
* event time (action callbacks, click handlers) — not to derive per-row UI during composition, which
41+
* would reintroduce the whole-list invalidation this holder exists to avoid.
42+
*/
43+
val selected: Set<T> get() = _selected
44+
45+
/** `true` while anything is selected. Notifies only on the empty <-> non-empty transition. */
46+
val isActive: Boolean by derivedStateOf { _selected.isNotEmpty() }
47+
48+
/** Number of selected items. Notifies only when the count changes (e.g. top-bar "N selected"). */
49+
val count: Int by derivedStateOf { _selected.size }
50+
51+
/**
52+
* Per-id membership as a Compose-isolated read. Only rows whose membership flips recompose.
53+
* Must be called from composable scope (e.g. inside a Lazy item).
54+
*/
55+
@Composable
56+
fun isSelected(id: T): Boolean {
57+
val state = remember(this, id) { derivedStateOf { id in _selected } }
58+
return state.value
59+
}
60+
61+
/**
62+
* Event-time membership check (click handlers). Does NOT create a composition subscription — do not
63+
* call this in composition to derive per-row state; use [isSelected] there.
64+
*/
65+
fun contains(id: T): Boolean = id in _selected
66+
67+
fun toggle(id: T) {
68+
_selected = if (id in _selected) _selected - id else _selected + id
69+
}
70+
71+
fun select(id: T) {
72+
_selected = _selected + id
73+
}
74+
75+
fun deselect(id: T) {
76+
_selected = _selected - id
77+
}
78+
79+
fun setSelection(ids: Set<T>) {
80+
_selected = ids.toSet()
81+
}
82+
83+
fun clear() {
84+
_selected = emptySet()
85+
}
86+
87+
/** Drop ids that are no longer present (replaces the prune `intersect`); skips a no-op write. */
88+
fun retainAll(ids: Set<T>) {
89+
val next = _selected intersect ids
90+
if (next.size != _selected.size) _selected = next
91+
}
92+
93+
companion object {
94+
fun <T : Any> saver() = listSaver<SelectionState<T>, T>(
95+
save = { it._selected.toList() },
96+
restore = { SelectionState(it.toSet()) },
97+
)
98+
}
99+
}
100+
101+
@Composable
102+
fun <T : Any> rememberSelectionState(): SelectionState<T> = rememberSaveable(
103+
saver = SelectionState.saver(),
104+
) { SelectionState() }
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package eu.darken.sdmse.common.compose.selection
2+
3+
import androidx.compose.foundation.layout.Column
4+
import androidx.compose.material3.Text
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.runtime.SideEffect
7+
import androidx.compose.runtime.getValue
8+
import androidx.compose.runtime.mutableStateOf
9+
import eu.darken.sdmse.common.compose.preview.PreviewWrapper
10+
import org.junit.Assert.assertEquals
11+
import org.junit.Test
12+
import testhelpers.compose.BaseComposeRobolectricTest
13+
14+
/**
15+
* Verifies the recomposition-isolation contract of [SelectionState] — the whole point of the holder.
16+
*
17+
* Each probe row bumps a per-id counter in a [SideEffect]; a [SideEffect] only runs for a row that
18+
* actually (re)composed, so counter deltas measure recompositions.
19+
*/
20+
class SelectionStateIsolationTest : BaseComposeRobolectricTest() {
21+
22+
@Composable
23+
private fun ProbeRow(id: Int, holder: SelectionState<Int>, counters: MutableMap<Int, Int>) {
24+
val selected = holder.isSelected(id)
25+
SideEffect { counters[id] = (counters[id] ?: 0) + 1 }
26+
Text("row$id=$selected")
27+
}
28+
29+
@Composable
30+
private fun ModeAwareRow(id: Int, holder: SelectionState<Int>, counters: MutableMap<Int, Int>) {
31+
val selected = holder.isSelected(id)
32+
val active = holder.isActive
33+
SideEffect { counters[id] = (counters[id] ?: 0) + 1 }
34+
Text("row$id=$selected active=$active")
35+
}
36+
37+
@Test
38+
fun `steady-active toggle recomposes only the toggled row`() {
39+
val holder = SelectionState<Int>()
40+
val counters = mutableMapOf<Int, Int>()
41+
// Pre-select a row so selection is already active — isolate the steady-state behavior from the
42+
// empty<->non-empty transition.
43+
holder.select(0)
44+
45+
composeRule.setContent {
46+
PreviewWrapper {
47+
Column {
48+
(0..4).forEach { ProbeRow(it, holder, counters) }
49+
}
50+
}
51+
}
52+
composeRule.waitForIdle()
53+
val baseline = counters.toMap()
54+
55+
composeRule.runOnIdle { holder.toggle(2) }
56+
composeRule.waitForIdle()
57+
58+
(0..4).forEach { id ->
59+
val delta = (counters[id] ?: 0) - (baseline[id] ?: 0)
60+
assertEquals("row $id recompositions after toggling row 2", if (id == 2) 1 else 0, delta)
61+
}
62+
}
63+
64+
@Test
65+
fun `entering selection mode recomposes all mode-aware rows`() {
66+
val holder = SelectionState<Int>()
67+
val counters = mutableMapOf<Int, Int>()
68+
69+
composeRule.setContent {
70+
PreviewWrapper {
71+
Column {
72+
(0..4).forEach { ModeAwareRow(it, holder, counters) }
73+
}
74+
}
75+
}
76+
composeRule.waitForIdle()
77+
val baseline = counters.toMap()
78+
79+
// empty -> active flips isActive, which every mode-aware row reads: all recompose once (expected).
80+
composeRule.runOnIdle { holder.select(0) }
81+
composeRule.waitForIdle()
82+
83+
(0..4).forEach { id ->
84+
val delta = (counters[id] ?: 0) - (baseline[id] ?: 0)
85+
assertEquals("row $id recompositions when entering selection mode", 1, delta)
86+
}
87+
}
88+
89+
@Test
90+
fun `isActive notifies only on the empty to non-empty transition`() {
91+
val holder = SelectionState<Int>()
92+
var bannerCompositions = 0
93+
94+
composeRule.setContent {
95+
PreviewWrapper {
96+
val active = holder.isActive
97+
SideEffect { bannerCompositions++ }
98+
Text("active=$active")
99+
}
100+
}
101+
composeRule.waitForIdle()
102+
val afterInitial = bannerCompositions
103+
104+
composeRule.runOnIdle { holder.select(1) } // empty -> active: notifies
105+
composeRule.waitForIdle()
106+
assertEquals(1, bannerCompositions - afterInitial)
107+
val afterFirst = bannerCompositions
108+
109+
composeRule.runOnIdle { holder.select(2) } // active -> active: no notify
110+
composeRule.waitForIdle()
111+
assertEquals(0, bannerCompositions - afterFirst)
112+
113+
composeRule.runOnIdle { holder.clear() } // active -> empty: notifies
114+
composeRule.waitForIdle()
115+
assertEquals(1, bannerCompositions - afterFirst)
116+
}
117+
118+
@Test
119+
fun `isSelected re-evaluates when the id argument changes`() {
120+
val holder = SelectionState(setOf(10))
121+
val idState = mutableStateOf(10)
122+
var lastSelected = false
123+
124+
composeRule.setContent {
125+
PreviewWrapper {
126+
val id by idState
127+
val selected = holder.isSelected(id)
128+
SideEffect { lastSelected = selected }
129+
Text("sel=$selected")
130+
}
131+
}
132+
composeRule.waitForIdle()
133+
assertEquals(true, lastSelected)
134+
135+
composeRule.runOnIdle { idState.value = 11 }
136+
composeRule.waitForIdle()
137+
assertEquals(false, lastSelected)
138+
139+
composeRule.runOnIdle { idState.value = 10 }
140+
composeRule.waitForIdle()
141+
assertEquals(true, lastSelected)
142+
}
143+
}

0 commit comments

Comments
 (0)