selection_sheet 0.5.0 copy "selection_sheet: ^0.5.0" to clipboard
selection_sheet: ^0.5.0 copied to clipboard

A production-ready adaptive selection workflow for Flutter.

selection_sheet #

Adaptive, searchable selection workflows for Flutter.

selection_sheet provides typed single and multi-selection sheets with local and remote search, pagination, grouped results, draggable heights, keyboard avoidance, Material/Cupertino adaptation, and a theming system that supports both global defaults and per-sheet overrides.

The example app is an interactive 0.5.0 showcase covering form, modal, remote paginated, custom-search, and embedded selection workflows.

Single selection #

final country = await SelectionSheet.showSingle<Country>(
  context: context,
  items: countries,
  searchable: true,
  searchDebounceDuration: const Duration(milliseconds: 400),
  title: 'Country',
  itemLabelBuilder: (country) => country.name,
);

Optional view configuration #

Both modal helpers accept an optional SelectionSheetViewItem<T>. It groups the source and common view values when that is more convenient:

final country = await SelectionSheet.showSingle<Country>(
  context: context,
  item: SelectionSheetViewItem(
    items: countries,
    initialValue: selectedCountry,
    pageSize: 20,
    title: 'Country',
    hintText: 'Search countries',
  ),
  searchable: true,
  itemLabelBuilder: (country) => country.name,
  itemKeyBuilder: (country) => country.code,
);

For showMulti, use initialSelection instead of initialValue. The configuration object is not required: all existing direct arguments continue to work. When both forms provide the same value, the direct helper argument takes precedence.

Search uses a 300 ms debounce by default. Override it for an individual sheet with searchDebounceDuration, or configure it globally:

SelectionSheetThemeData.fallback(context).copyWith(
  searchDebounceDuration: const Duration(milliseconds: 500),
);

Use Duration.zero when filtering should happen immediately.

Custom search input #

Provide searchFieldBuilder to replace the built-in Material or Cupertino search input. The builder receives the controller, focus node, resolved hint, current debounced query, and the callbacks that keep filtering in sync:

SelectionSheet.showSingle<Country>(
  context: context,
  items: countries,
  itemLabelBuilder: (country) => country.name,
  searchFieldBuilder: (context, search) {
    return TextField(
      controller: search.controller,
      focusNode: search.focusNode,
      onChanged: search.onChanged,
      decoration: InputDecoration(
        hintText: search.hintText,
        prefixIcon: const Icon(Icons.travel_explore),
        suffixIcon: search.query.isEmpty
            ? null
            : IconButton(
                onPressed: search.onClear,
                icon: const Icon(Icons.clear),
              ),
      ),
    );
  },
);

A per-sheet builder enables search automatically. Configure searchFieldBuilder in SelectionSheetThemeData for a global search design; globally configured builders are used by sheets that set searchable: true. A per-sheet builder always takes precedence over the global builder.

Remote search and pagination #

Use loadItems instead of items. Every request contains the current debounced query, a one-based page number, the requested page size, and the cursor returned by the previous page.

final users = await SelectionSheet.showMulti<User>(
  context: context,
  searchable: true,
  pageSize: 30,
  itemLabelBuilder: (user) => user.name,
  loadItems: (request) async {
    final result = await repository.searchUsers(
      query: request.query,
      page: request.page,
      limit: request.pageSize,
      cursor: request.cursor,
    );

    return SelectionSheetPage(
      items: result.users,
      hasMore: result.hasMore,
      nextCursor: result.nextCursor,
    );
  },
);

Page-based repositories can ignore request.cursor. Cursor-based repositories can ignore request.page. When a new search begins, responses from all older search generations are ignored automatically.

Cancelling async work #

Every request includes a cooperative cancellation token. It is cancelled when a search is superseded, the sheet refreshes, or the route closes:

loadItems: (request) async {
  final operation = api.searchUsers(
    query: request.query,
    cursor: request.cursor,
  );
  final removeListener = request.cancellationToken.onCancel(operation.cancel);

  try {
    final response = await operation.value;
    request.cancellationToken.throwIfCancelled();
    return SelectionSheetPage(
      items: response.users,
      hasMore: response.hasMore,
      nextCursor: response.nextCursor,
    );
  } finally {
    removeListener();
  }
},

Repositories that cannot cancel their underlying future can inspect request.cancellationToken.isCancelled. Stale responses are still rejected by the sheet even when the repository ignores cancellation.

Refreshing remote results #

Pass a controller when another part of the application needs to refresh the open sheet:

final selectionController = SelectionSheetController();

SelectionSheet.showMulti<User>(
  context: context,
  controller: selectionController,
  loadItems: repository.loadUsers,
  itemLabelBuilder: (user) => user.name,
);

await selectionController.refresh();

refresh() reloads page one for the current debounced query, resets the cursor, invalidates older requests, and preserves the draft selection. Remote result lists also support pull-to-refresh by default. Set enablePullToRefresh: false to disable that gesture.

Multi-selection #

Multi-selection is kept as a draft until the user confirms it.

final countries = await SelectionSheet.showMulti<Country>(
  context: context,
  items: allCountries,
  initialSelection: selectedCountries,
  searchable: true,
  title: 'Countries',
  itemLabelBuilder: (country) => country.name,
);

Selected items appear as removable chips by default. Customize them with a typed builder or disable them with showSelectedChips: false:

selectedChipBuilder: (context, country, label, onDeleted) {
  return InputChip(
    avatar: CountryFlag(code: country.code),
    label: Text(label),
    onDeleted: onDeleted,
  );
},

Embedded selection view #

Use SelectionSheetView<T> when the selection workflow belongs inside an existing page, dialog, side panel, or custom sheet instead of opening a modal:

SelectionSheetView<Country>.multi(
  title: 'Countries',
  items: countries,
  initialSelection: selectedCountries,
  searchable: true,
  itemLabelBuilder: (country) => country.name,
  itemKeyBuilder: (country) => country.id,
  onSelectionChanged: (value) {
    setState(() => selectedCountries = value);
  },
  onConfirmed: saveCountries,
);

The .single constructor uses initialValue and onSelected. The .multi constructor uses initialSelection, onSelectionChanged, and onConfirmed. The embedded view does not pop a route when an item is selected or Done is pressed. It accepts the same local/remote source, search, pagination, sections, grid, state builders, theme, and controller options as the modal workflow.

Sections and sticky headers #

SelectionSheet.showSingle<User>(
  context: context,
  items: users,
  itemLabelBuilder: (user) => user.name,
  sectionBuilder: (user) => user.department,
  stickySectionHeaders: true,
  sectionHeaderBuilder: (context, section) {
    return ColoredBox(
      color: Theme.of(context).colorScheme.surface,
      child: Text('${section.label} (${section.itemCount})'),
    );
  },
);

Section order follows the first occurrence of each key in the loaded items.

Grid presentation #

Grid mode uses the same local/remote search, pagination, sections, selection, and item builders as list mode:

SelectionSheet.showMulti<Product>(
  context: context,
  items: products,
  itemLabelBuilder: (product) => product.name,
  layout: SelectionSheetLayout.grid,
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    childAspectRatio: 0.8,
    crossAxisSpacing: 12,
    mainAxisSpacing: 12,
  ),
  itemBuilder: (context, product, state) {
    return ProductSelectionCard(
      product: product,
      selected: state.isSelected,
    );
  },
);

Set layout and gridDelegate in SelectionSheetThemeData to make grid mode the application-wide default.

Native form fields #

The package integrates directly with Flutter's Form and FormField APIs and does not depend on a third-party form package.

Form(
  key: formKey,
  child: SelectionSheetFormField<Country>(
    items: countries,
    searchable: true,
    itemLabelBuilder: (country) => country.name,
    decoration: const InputDecoration(labelText: 'Country'),
    validator: (country) {
      return country == null ? 'Country is required' : null;
    },
    onSaved: (country) => profile.country = country,
  ),
);

For multiple values, use SelectionSheetMultiFormField<T>. Both widgets support local and remote sources, sections, custom item rows, grid layout, validation, saving, clearing, and AutovalidateMode. hintText is optional, so a field with InputDecoration(labelText: 'Country') does not show a redundant selection prompt unless one is explicitly provided.

Loading, empty, and error states #

All initial and pagination states have per-sheet builders:

SelectionSheet.showSingle<User>(
  context: context,
  loadItems: repository.loadUsers,
  itemLabelBuilder: (user) => user.name,
  loadingBuilder: (context) => const UserListSkeleton(),
  emptyBuilder: (context, query) => EmptyUsers(query: query),
  errorBuilder: (context, error, retry) {
    return ErrorPanel(error: error, onRetry: retry);
  },
  loadingMoreBuilder: (context) => const LoadingMoreRow(),
  loadMoreErrorBuilder: (context, error, retry) {
    return RetryPageRow(onRetry: retry);
  },
);

The same builders can be configured globally in SelectionSheetThemeData.

Custom item rows #

The label builder remains the source for search and semantics. A typed item builder can replace the visual row:

final country = await SelectionSheet.showSingle<Country>(
  context: context,
  items: countries,
  itemLabelBuilder: (country) => country.name,
  itemBuilder: (context, country, state) {
    return ListTile(
      leading: CountryFlag(code: country.code),
      title: Text(country.name),
      selected: state.isSelected,
      trailing: state.isSelected ? const Icon(Icons.check) : null,
    );
  },
);

Stable item identity #

For model objects, especially remotely loaded or paginated data, provide itemKeyBuilder:

itemKeyBuilder: (country) => country.id,

The key is used to match initial selections with newly loaded model instances, check selected state efficiently, remove selections, and deduplicate repeated items across pages. When provided, it takes precedence over itemEquals. Keys must be stable and unique within the data source.

Global design #

Place SelectionSheetTheme inside MaterialApp.builder:

MaterialApp(
  builder: (context, child) {
    return SelectionSheetTheme(
      data: SelectionSheetThemeData.fallback(context).copyWith(
        selectedColor: Colors.indigo.shade50,
        showDividers: true,
        itemBuilder: (context, item) {
          return AppSelectionTile(
            label: item.label,
            selected: item.isSelected,
          );
        },
      ),
      child: child!,
    );
  },
  home: const App(),
);

A typed itemBuilder passed to showSingle or showMulti overrides the global builder. A sheet can also override design tokens:

SelectionSheet.showSingle<Country>(
  context: context,
  items: countries,
  itemLabelBuilder: (country) => country.name,
  theme: SelectionSheetTheme.of(context).copyWith(
    selectedColor: Colors.green.shade100,
    showDividers: false,
  ),
);

Roadmap #

  • Keyboard navigation for desktop grids
  • Request caching policies
  • Accessibility and localization audit
0
likes
0
points
251
downloads

Publisher

verified publisherpinz.dev

Weekly Downloads

A production-ready adaptive selection workflow for Flutter.

Repository (GitHub)
View/report issues

Topics

#bottom-sheet #selection #search #picker #flutter

License

unknown (license)

Dependencies

flutter

More

Packages that depend on selection_sheet