SuperAPI AI Directory

selection-mode-state-holder

Frontend & websites

View repository

Updated 10-17 16:16

About this skill

A multi-select state holder

One @Stable class, held by the screen, read by the row. Two pieces of observable state — whether the mode is on, and which ids are picked — and exactly one way to add to the second:

// adapted
@Stable
class SelectionState(private val limitMessage: String) {
    var isActive by mutableStateOf(false); private set

    private val _selected = mutableStateListOf<String>()
    val selected: List<String> get() = _selected
    val count: Int get() = _selected.size
    val isFull: Boolean get() = _selected.size >= MAX_SELECTION

    fun isSelected(id: String): Boolean = _selected.contains(id)
/** The only way into [_selected] — which is what makes the cap hold. */
private fun add(id: String): Boolean {
    if (id.isBlank() || _selected.contains(id)) return true
    if (isFull) { notifyLimitReached(limitMessage); return false }
    _selected.add(id)
    return true
}

}


`start(id)` (long press) turns the mode on and adds one. `toggle(id)` (tap while active) removes or
adds. `toggleSelectAll(ids)` fills up to the cap, or clears. `exit()` turns the mode off and empties.
Every **addition** among them goes through `add` — that is the choke-point, and it is only about
growth. Removals stay direct (`toggle` calls `remove`, select-all and `exit` call `clear`), because
nothing has to be checked on the way out.

## Traps

**Track ids, not list positions.** An index identifies a row only until the list changes shape, and
the lists that get multi-select are exactly the ones that do: paged lists that grow underneath you,
reorderable lists where a drag renumbers everything between the source and the destination. After
either, an index-keyed selection is silently pointing at different rows than the ones the user
ticked — and a bulk action then runs on those. If the same screen also supports drag-to-reorder (see
`lazy-list-drag-reorder`), this is not a hypothetical.

**One private mutator is what makes the cap real.** There are three ways into the selection — long
press, tap, select-all — and a cap checked at each of them is a cap that survives until someone adds
a fourth. Route all three through one private `add`, check there, and the check cannot be bypassed
by the path nobody remembered. The same argument applies to normalisation: `add` rejects blanks and
duplicates, so no caller has to.

**The mutator returns a Boolean so bulk callers can stop.** Select-all loops; without a return value
it either keeps calling past the cap (firing the "limit reached" feedback once per remaining item —
dozens of toasts from one tap) or it needs its own copy of the cap check, which is the duplication
the choke-point exists to prevent:

```kotlin
// adapted
for (id in candidates) { if (!add(id)) return }

Test "everything is already picked" against what select-all would pick, not against the whole list. Select-all doubles as deselect-all: pressing it again when its work is already done should clear. But it only ever picks the first MAX_SELECTION candidates, so comparing against the full input list can never report "all picked" on any list longer than the cap — and the button becomes one-way. Compare against the truncated set:

// adapted
val candidates = ids.filter { it.isNotBlank() }.distinct()
val everythingReachableIsPicked =
    candidates.isNotEmpty() && candidates.take(MAX_SELECTION).all { _selected.contains(it) }
if (everythingReachableIsPicked) { _selected.clear(); return }

Clearing by tap and clearing by select-all must end differently. Tapping rows off one at a time until none are left reads as leaving the mode, so toggle calls exit() when it empties the list — otherwise the user is stranded in an action bar with nothing to act on and no obvious way out. Pressing select-all a second time reads as starting over, so it clears the list and deliberately stays active. Same end state, two intentions; collapsing them makes one of the two gestures feel broken.

Expose counts as derived getters over the snapshot list, not as separate state. count and isFull computed from _selected.size cannot go stale, and a header reading state.count subscribes to the snapshot list and recomposes on change without holding the list itself. A cached var count beside the list is a second source of truth for the same fact.

@Stable is a promise the class has to keep. It tells the compiler that every publicly readable property changes only through the snapshot system, so composables reading this holder may be skipped when it has not changed. isActive is mutableStateOf and _selected is mutableStateListOf, so the promise holds. Add one plain var that the UI reads and the annotation becomes a lie: the UI stops recomposing when that field changes, with no warning anywhere.

A remembered holder keyed on a localised string is rebuilt when the locale changes. Building the limit message with a string resource and keying remember on it is convenient, but it means the selection is dropped on a language switch — and plain remember drops it on configuration change and process death anyway. If a selection must survive those, hold it in the screen's view model instead and pass the message in at the call.

Reporting the limit from inside the holder is a deliberate trade, not an accident. Putting the notification next to the check keeps the cap in one place; the cost is that the state class now depends on a UI notification API and cannot be exercised off the main thread. If that matters, have add return a richer result and let the screen render it — but keep the decision in the holder, or the cap fragments again.

Verifying it

  1. Exactly one write path into the backing list. grep -n '_selected\.' <holderFile> → observed: one _selected.add(...), inside the private mutator; one _selected.remove(...) in toggle; two _selected.clear() (select-all and exit); everything else is a read (.size, .contains, .isEmpty). A second .add anywhere means the cap has a bypass.
  2. The cap constant is referenced by the check and by the truncation, and nowhere re-derived. grep -rn '<capConstant>' --include='*.kt' . → observed: the declaration, the isFull comparison, the take(...) in select-all, the message formatting, and two references from documentation comments. A numeric literal for the same limit at any call site is a second cap.
  3. Every screen that turns the mode on also wires the toggle. Compare the two per-file counts:
    grep -rc 'selectionMode =' --include='*.kt' . | grep -v ':0$' | sort
    grep -rc 'onSelectToggle =' --include='*.kt' . | grep -v ':0$' | sort
    
    → observed: the two listings match file for file and count for count, except one file whose selectionMode belongs to an unrelated file-picker API — worth knowing, because that is what a false positive looks like here. A genuine mismatch is a screen that renders checkboxes nothing can tick. Note the patterns carry no trailing space: a wrapped argument puts the value on the next line, and 'onSelectToggle = ' silently misses those.
  4. By hand: select up to the cap, then press select-all. Nothing should be added and exactly one notification should appear — a burst of them means a caller is looping past a mutator whose return value it ignores.
  5. By hand on a reorderable list: select two rows, drag a third between them, and confirm the same two rows are still ticked. If the ticks moved, the holder is keyed on positions.