Expand description
The M3E selection pattern: an index-set selection mode over a list of items, entered by a long-press and exited by clearing the selection, plus the contextual bar that swaps in over an idle app bar while it is active.
§Controller: pure functions, not an owned controller
The reference’s M3ESelectionController is a ChangeNotifier an app
mutates in place (select/deselect/toggle/selectAll/clear) and
listens to. This crate’s controlled-component convention has no
self-mutating retained state to notify from (docs/CODE_STANDARDS.md’s
Interaction Semantics: “controlled components never self-mutate”) — so
this module ports the same five mutations as pure functions over an
app-owned BTreeSet<usize>, each returning the next set for the app to
adopt into its own signal: select, deselect, toggle,
select_all, cleared. is_selection_mode mirrors the reference’s
derived isSelectionMode getter exactly (!selected.is_empty()) — there
is no independent “in selection mode” flag anywhere in this module; mode
is always recomputed from the live selected set, which is what makes
the controlled semantics below hold. is_selected and
all_selected_for port the remaining read-only queries
(isSelected/allSelectedFor) verbatim.
§Wrapper: selection_host
selection_host wraps arbitrary frust::authoring::AnyView item rows
(a list_item/card_list row, or any other content) as a single
interactive target per row — the same “the whole row owns capture”
convention super::card_list and super::list_item establish.
An item passed here should not chain its own on_press (or any other
row-level press handling): the host owns Down/Move/Up/Cancel for the whole
row and never forwards a pointer event into a captured row’s children (a
caller wanting a second, independently-tappable target inside a row —
the reference’s tap-the-leading-avatar-to-toggle affordance — is exactly
what this port does not carry forward; see Not ported below).
Two outcomes resolve a tap, mutually exclusively, exactly like the
reference’s own _onTap/_onLongPress pairing (see
example/lib/pages/playground/view/selection_playground.dart’s
_SelectionDemoHostState, the reference’s own worked wiring of
M3ESelectionController against a plain list):
- Tap (
Upreleased inside the same row aDownarmed): whileis_selection_modereadstrue, firesSelectionHostView’son_togglewith the row’s index (_onTap’sselection.toggle(index)branch); otherwise fireson_activate(_onTap’selsebranch — the row’s ordinary action, e.g. opening it). - Long-press (held past [
LONG_PRESS_MS], 500ms): fireson_togglewith the row’s index only when it is not already selected — ported verbatim from_onLongPress’s own guard (if (!isSelected(index)) select(index)), so a long-press can enter or extend selection mode but never exits it by deselecting the row it landed on. Movement pastfrust::input::TOUCH_SLOPfrom the initiatingDowncancels the long-press candidacy (a drag, not a hold) without cancelling the row’s own tap/toggle resolution atUp.
paint is the only pass carrying a clock in this framework, so the
long-press threshold is latched there and fires on the next pointer event
that arrives after it (a Move, or Up as the final fallback) — the same
frust_core::mark_pending_result_flush-unreachable-from-a-plugin
constraint super::toggle_button’s own Long-press section documents
at length; this module’s [LongPressState] is that same shape, ported
fresh here (docs/PLUGINS_CODE_STANDARDS.md’s design-system charter: no
frust-core dependency in production, so frust_widgets::gesture’s
on_long_press primitive — which does reach that function — is
unreachable from this crate either way).
selection_host paints no chrome of its own (no selection fill, no
pressed/hover overlay): the item view a caller supplies owns every visual
— a selected row’s checkmark/fill is the caller’s own
list_item(...).selected(is_selected(&selected, i)) (or a
[crate::checkbox] wired as its leading slot), exactly as the reference’s
own _item/_leading builder keys every visual off _selection.isSelected (index) rather than the selection host painting it centrally.
§Contextual bar: selection_app_bar
selection_app_bar wraps an idle app bar (or any other intrinsic-
height content) and swaps to a contextual toolbar — a leading close
affordance, a selected-count label, caller-supplied trailing actions, and
an optional select-all row — whenever is_selection_mode reads true
for the selected set it was built with. The swap is a plain structural
rebuild (the idle branch is torn down, the contextual one built, or vice
versa — frust::authoring::AnyView’s own type-changed rebuild already
takes this shape for a single child; here it is two whole sibling sets of
children reconciled the same way), not an animated cross-fade: the
reference’s own AnimatedSize + AnimatedSwitcher (220ms enter / 90ms
exit, Cubic(0.2, 0, 0, 1) / Cubic(0.4, 0, 1, 1)) motion is out of this
module’s v1 scope — see Not ported below.
Metrics cited against appbar.rs. The contextual toolbar matches the
idle app bar’s own band so the header reads as one continuous surface
across the swap: [APP_BAR_HEIGHT]/[APP_BAR_PAD_X]/[APP_BAR_GAP]
mirror appbar.rs’s private HEIGHT/PAD_X/GAP exactly (64dp / 4dp /
4dp) — duplicated here since those constants are private to that module,
the same citation shape super::toggle_button’s
ToggleButtonSize::square_radius uses for a private sibling-module value.
This bar is its own surface, not a shared base type over the app bar.
Window insets: the contextual branch insets like the idle bar. An idle
crate::app_bar/crate::search_app_bar consumes the top, left and
right window insets itself by default (Flutter’s AppBar). The contextual
branch does the same: it grows by padding().top, lays its close button,
count, actions and select-all row out below it and inside the side
insets, and paints its primary_container fill edge to edge through the
whole band. So the swap keeps one height and one set of slot offsets in
every composition — unwrapped (both branches consume the inset) or under
a consuming frust::safe_area(..) (both see 0). The idle view is an
opaque AnyView, so its own .safe_area(..) cannot be read from here:
this bar mirrors the app bar’s default, and a caller that opts its idle
bar out with .safe_area(false) opts this one out too
(SelectionAppBarView::safe_area). The contextual branch does not apply
the app bar’s window-control corner_shift.
§Controlled semantics
Neither SelectionHostWidget nor SelectionAppBarWidget retains an
independent “in selection mode” flag: both recompute is_selection_mode
from the selected prop on every rebuild/layout/paint. An app that
clears its own selected set externally (a system-back handler, a
“done” action elsewhere in the UI) collapses the contextual bar back to
idle and restores tap-to-activate on the very next rebuild — no
on_clear/on_toggle round trip is needed to leave selection mode, only
to enter or extend it (mirrors the reference’s own PopScope
guidance: “system back clears selection”, documented on [M3ESelection]
→ selection_host/selection_app_bar here — see this module’s own
doc example on selection_app_bar).
§Not ported
M3ESelectionLeading’s 3D Y-axis flip (Transform+Matrix4, a perspective rotation between the unselected and selected leading faces). This framework’sfrust::authoring::PaintScenetakes only 2D affine paint calls — no perspective/3D primitive exists to port arotateYflip onto. A caller wanting a selected-state leading visual swaps its own leadingAnyView(e.g. an avatar for a [crate::checkbox]) keyed offis_selected, the same shapelist_item’s ownleadingslot already takes any view.- The reference’s per-leading tap target (
M3ESelectionLeading(onTap: () => controller.toggle(index)), a second independently-tappable zone layered over just the leading slot).selection_hostmakes the whole row one interactive target, the single-target-per-row convention every other interactive row in this catalog follows (super::list_item,super::card_list). M3ESelection’sScaffoldwrapping (scaffold/backgroundColor/resizeToAvoidBottomInset) — Flutter-Scaffold-specific chrome this crate has no analogue for; a caller composesselection_app_baraboveselection_hostin its own layout, the same way an idleapp_barand its body are composed today (no scaffold widget exists in this crate).M3ESelectionScope’sInheritedWidgetcontext resolution (M3ESelectionAppBar’s optional-controller-falls-back-to-scope pattern). Every collaborator here is an explicit prop instead, matching every other component in this crate.- The
AnimatedSize/AnimatedSwitchercontextual-bar transition — see the Contextual bar section above.
§Attribution
See plugins/material/NOTICE’s “MIT License — Additional Copyright Holders
(Vendored Components)” section and its Module Attribution Header
Convention. MaterialTokens-only: no new theme-extension type is
introduced here (unlike the reference’s own M3ESelectionTheme) — every
color/shape resolution below reads Theme/ColorScheme directly, the
same pattern list_item/card_list already establish.
Structs§
- Selection
AppBar View - A declarative selection-aware app bar:
idlecontent, swapping to a contextual toolbar whileselectedis non-empty. See the module docs’ Contextual bar section. - Selection
AppBar Widget - The retained widget for a
SelectionAppBarView. See the module docs. - Selection
Host View - A declarative selection-mode item wrapper. See the module docs.
- Selection
Host Widget - The retained widget for a
SelectionHostView. See the module docs.
Functions§
- all_
selected_ for - A tristate value for a select-all control given
item_count(M3ESelectionController.allSelectedFor):Some(true)when every index is selected,Some(false)when none are,Nonewhen some but not all are. - cleared
- The cleared (empty) selection (
M3ESelectionController.clear). - deselect
- The set with
indexdeselected, if it was selected (M3ESelectionController.deselect). - is_
selected - Whether
indexis selected (M3ESelectionController.isSelected). - is_
selection_ mode - Whether
selectedputs the surface in selection mode (M3ESelectionController.isSelectionMode). - select
- The set with
indexselected, if it was not already (M3ESelectionController.select). - select_
all - Every index in
0..item_count, selected (M3ESelectionController.selectAll). - selection_
app_ bar - Create a selection app bar showing
idlewhileselectedis empty, and the contextual toolbar (count + close + actions) otherwise.on_clearfires when the close affordance is pressed — the app is the source of truth forselected, so this is the only way this bar’s own close button ever empties it (see the module docs’ Controlled semantics section). - selection_
host - Wrap
itemsin the selection gesture router: tap toggles membership whileselectedis non-empty, otherwise fireson_activate(SelectionHostView::on_activate); a long-press selects an unselected row (see the module docs’ Wrapper section).on_togglereports the row index whose membership should flip. - toggle
- The set with
index’s membership flipped (M3ESelectionController.toggle).