Perché SearchAnchor

Per anni la ricerca in-app in Flutter è passata da showSearch() e SearchDelegate: una soluzione funzionante ma rigida, che apre una route a schermo intero e costringe a sottoclassare una classe astratta per ogni tipo di ricerca.

Con Material 3 sono arrivati due widget molto più componibili: SearchBar (il campo di ricerca vero e proprio) e SearchAnchor (l'ancora che apre una view di suggerimenti sopra o al posto della barra). Il vantaggio principale è che la view dei suggerimenti non è una route separata ma un overlay controllato dal framework: puoi decidere se mostrarla a schermo intero (comportamento tipico su mobile) o come pannello agganciato alla barra (tipico su tablet, desktop e web).

SearchDelegate non è deprecato e resta utilizzabile, ma per nuove app conviene partire da SearchAnchor: è più flessibile, più aderente alle linee guida Material 3 e si integra meglio con layout adattivi.

Il caso più semplice: SearchAnchor.bar

Il costruttore SearchAnchor.bar crea in un colpo solo la barra e la view associata:

class SimpleSearch extends StatelessWidget {
  const SimpleSearch({super.key, required this.items});

  final List<String> items;

  @override
  Widget build(BuildContext context) {
    return SearchAnchor.bar(
      barHintText: 'Cerca un prodotto',
      barLeading: const Icon(Icons.search),
      suggestionsBuilder: (context, controller) {
        final query = controller.text.toLowerCase();
        final results = items
            .where((item) => item.toLowerCase().contains(query))
            .take(10);

        return results.map(
          (item) => ListTile(
            title: Text(item),
            onTap: () {
              // Chiude la view e scrive il valore nella barra
              controller.closeView(item);
            },
          ),
        );
      },
    );
  }
}

Tre concetti da fissare subito:

  • suggestionsBuilder viene invocato ogni volta che il testo cambia (e all'apertura della view). Restituisce un Iterable<Widget> oppure un Future<Iterable<Widget>>: il supporto asincrono è nativo.
  • SearchController estende TextEditingController e aggiunge openView(), closeView(String? text) e isOpen. È il ponte fra barra e view.
  • Il widget della view viene ricostruito automaticamente: non serve gestire uno State per la lista dei suggerimenti.

Separare barra e view con SearchAnchor

Quando la barra deve avere un aspetto diverso dal default — per esempio un semplice IconButton nella AppBar — si usa il costruttore standard con builder:

SearchAnchor(
  isFullScreen: MediaQuery.sizeOf(context).width < 600,
  viewHintText: 'Cerca fra gli ordini',
  builder: (context, controller) {
    return IconButton(
      icon: const Icon(Icons.search),
      tooltip: 'Cerca',
      onPressed: controller.openView,
    );
  },
  suggestionsBuilder: (context, controller) async {
    // ...
    return const <Widget>[];
  },
);

isFullScreen è il parametro chiave per le UI adattive: true su smartphone, false su schermi larghi, dove la view resta ancorata sotto la barra come un menu a tendina.

Suggerimenti da rete: il problema del debounce

Se i suggerimenti arrivano da un'API, chiamare il backend a ogni carattere digitato è il modo più veloce per saturare il server e far lampeggiare la UI. suggestionsBuilder è asincrono, ma il framework non applica alcun debounce: dobbiamo farlo noi.

Una piccola utility riusabile:

import 'dart:async';

class Debouncer {
  Debouncer({this.duration = const Duration(milliseconds: 350)});

  final Duration duration;
  Timer? _timer;
  Completer<dynamic>? _pending;

  /// Esegue [action] dopo [duration]. Se arriva una nuova chiamata prima
  /// dello scadere del timer, la precedente viene annullata e la sua
  /// Future completa con null.
  Future<T?> run<T>(Future<T> Function() action) {
    _timer?.cancel();
    if (_pending != null && !_pending!.isCompleted) {
      _pending!.complete(null);
    }

    final completer = Completer<T?>();
    _pending = completer;

    _timer = Timer(duration, () async {
      if (completer.isCompleted) return;
      try {
        completer.complete(await action());
      } catch (error, stack) {
        if (!completer.isCompleted) completer.completeError(error, stack);
      }
    });

    return completer.future;
  }

  void dispose() {
    _timer?.cancel();
    if (_pending != null && !_pending!.isCompleted) {
      _pending!.complete(null);
    }
  }
}

Il punto delicato è cosa mostrare quando una richiesta viene annullata. Se restituiamo una lista vuota, l'utente vede la view svuotarsi a ogni tasto. La soluzione è conservare l'ultimo risultato valido e ripresentarlo finché non arriva il nuovo:

class ProductSearch extends StatefulWidget {
  const ProductSearch({super.key, required this.repository});

  final ProductRepository repository;

  @override
  State<ProductSearch> createState() => _ProductSearchState();
}

class _ProductSearchState extends State<ProductSearch> {
  final _debouncer = Debouncer();
  final _searchController = SearchController();
  List<Product> _lastResults = const [];

  @override
  void dispose() {
    _debouncer.dispose();
    _searchController.dispose();
    super.dispose();
  }

  Future<Iterable<Widget>> _buildSuggestions(
    BuildContext context,
    SearchController controller,
  ) async {
    final query = controller.text.trim();
    if (query.length < 2) {
      return const [
        ListTile(
          leading: Icon(Icons.info_outline),
          title: Text('Digita almeno 2 caratteri'),
        ),
      ];
    }

    List<Product>? results;
    try {
      results = await _debouncer.run(() => widget.repository.search(query));
    } catch (_) {
      return const [
        ListTile(
          leading: Icon(Icons.error_outline),
          title: Text('Errore durante la ricerca'),
        ),
      ];
    }

    // null = richiesta annullata da una digitazione successiva
    if (results == null) {
      return _lastResults.map(_tileFor);
    }

    _lastResults = results;
    if (results.isEmpty) {
      return const [ListTile(title: Text('Nessun risultato'))];
    }
    return results.map(_tileFor);
  }

  Widget _tileFor(Product product) {
    return ListTile(
      key: ValueKey(product.id),
      leading: const Icon(Icons.inventory_2_outlined),
      title: Text(product.name),
      subtitle: Text(product.category),
      onTap: () {
        _searchController.closeView(product.name);
        Navigator.of(context).pushNamed('/product/${product.id}');
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return SearchAnchor.bar(
      searchController: _searchController,
      barHintText: 'Cerca prodotti',
      suggestionsBuilder: _buildSuggestions,
    );
  }
}

Nota il key: ValueKey(product.id) sulle tile: aiuta Flutter a riusare gli elementi quando la lista cambia parzialmente, riducendo i rebuild inutili.

Cronologia delle ricerche

Una ricerca ben fatta mostra qualcosa anche a campo vuoto. La cronologia recente è il contenuto più utile: la persistiamo con shared_preferences.

class SearchHistory {
  SearchHistory(this._prefs);

  static const _key = 'search_history';
  static const _maxEntries = 8;
  final SharedPreferences _prefs;

  List<String> get entries => _prefs.getStringList(_key) ?? const [];

  Future<void> add(String query) async {
    final value = query.trim();
    if (value.isEmpty) return;

    final updated = [value, ...entries.where((e) => e != value)]
        .take(_maxEntries)
        .toList();
    await _prefs.setStringList(_key, updated);
  }

  Future<void> remove(String query) async {
    await _prefs.setStringList(_key, entries.where((e) => e != query).toList());
  }
}

E nel suggestionsBuilder, quando la query è vuota:

Iterable<Widget> _buildHistory(SearchController controller) {
  final entries = _history.entries;
  if (entries.isEmpty) {
    return const [
      Padding(
        padding: EdgeInsets.all(16),
        child: Text('Le ricerche recenti appariranno qui'),
      ),
    ];
  }

  return entries.map(
    (query) => ListTile(
      leading: const Icon(Icons.history),
      title: Text(query),
      trailing: IconButton(
        icon: const Icon(Icons.close),
        tooltip: 'Rimuovi dalla cronologia',
        onPressed: () async {
          await _history.remove(query);
          // Forza la rigenerazione dei suggerimenti
          setState(() {});
        },
      ),
      onTap: () {
        controller.text = query;
        // Sposta il cursore in fondo
        controller.selection =
            TextSelection.collapsed(offset: query.length);
      },
    ),
  );
}

Quando l'utente conferma una ricerca (tap su un risultato oppure onSubmitted della barra) chiamiamo _history.add(query).

Evidenziare il testo cercato

Un dettaglio che fa percepire la ricerca come "professionale" è l'highlight della porzione di testo corrispondente alla query:

Widget highlight(String text, String query, TextStyle? base) {
  final index = text.toLowerCase().indexOf(query.toLowerCase());
  if (query.isEmpty || index < 0) return Text(text, style: base);

  return Text.rich(
    TextSpan(
      style: base,
      children: [
        TextSpan(text: text.substring(0, index)),
        TextSpan(
          text: text.substring(index, index + query.length),
          style: const TextStyle(fontWeight: FontWeight.bold),
        ),
        TextSpan(text: text.substring(index + query.length)),
      ],
    ),
  );
}

Personalizzare la view

SearchAnchor espone diversi parametri per il contenuto dell'overlay: viewBackgroundColor, viewElevation, viewShape, viewConstraints, headerTextStyle, dividerColor. Se serve un layout completamente diverso — per esempio una griglia di risultati con immagini — si usa viewBuilder, che riceve i widget prodotti da suggestionsBuilder:

SearchAnchor(
  viewBuilder: (suggestions) {
    return GridView.count(
      crossAxisCount: 2,
      padding: const EdgeInsets.all(12),
      childAspectRatio: 3,
      children: suggestions.toList(),
    );
  },
  builder: (context, controller) => SearchBar(
    controller: controller,
    hintText: 'Cerca',
    onTap: controller.openView,
    onChanged: (_) => controller.openView(),
    leading: const Icon(Icons.search),
  ),
  suggestionsBuilder: _buildSuggestions,
);

Attenzione al pattern onTap + onChanged quando si usa una SearchBar custom dentro builder: senza openView() la view non si apre e il campo resta una semplice text field.

Accorgimenti pratici

  • Gestisci il SearchController come qualsiasi controller: creane uno solo per widget, con dispose() nel State. Se non lo passi, SearchAnchor ne crea uno interno e lo gestisce da sé.
  • Limita il numero di suggerimenti: 8-10 elementi sono sufficienti; oltre, la view diventa una lista da scorrere e perde la sua funzione di scorciatoia.
  • Non usare suggestionsBuilder per lavoro pesante sincrono: filtrare 50.000 stringhe a ogni tasto blocca il frame. Sposta il filtro su un Isolate o su una query indicizzata del database locale.
  • Cache dei risultati: una semplice Map<String, List<Product>> in memoria evita di richiamare l'API per query già viste, utile quando l'utente cancella caratteri.
  • Accessibilità: SearchBar e SearchAnchor espongono già la semantica corretta, ma ricordati di valorizzare barHintText/viewHintText e i tooltip delle icone.
  • Localizzazione: il pulsante "indietro" e le etichette della view usano MaterialLocalizations; assicurati che flutter_localizations sia configurato, altrimenti resteranno in inglese.

Testare la ricerca

La view di SearchAnchor è un overlay, quindi nei widget test va aperta esplicitamente prima di verificarne il contenuto:

testWidgets('mostra i suggerimenti filtrati', (tester) async {
  await tester.pumpWidget(MaterialApp(
    home: Scaffold(body: SimpleSearch(items: const ['Mela', 'Melone', 'Pera'])),
  ));

  await tester.tap(find.byType(SearchBar));
  await tester.pumpAndSettle();

  await tester.enterText(find.byType(TextField).last, 'mel');
  await tester.pumpAndSettle();

  expect(find.text('Mela'), findsOneWidget);
  expect(find.text('Melone'), findsOneWidget);
  expect(find.text('Pera'), findsNothing);
});

Se hai introdotto il debounce, ricorda di far avanzare il tempo con await tester.pump(const Duration(milliseconds: 400)) prima delle asserzioni.

Conclusione

SearchAnchor e SearchBar coprono con poche righe di codice l'80% dei casi d'uso di una ricerca in-app, lasciando però tutti i punti di estensione necessari: view personalizzata, suggerimenti asincroni, comportamento adattivo fra mobile e desktop. Il lavoro "vero" resta quello di sempre — debounce, cache, cronologia e gestione degli stati vuoti o di errore — ma non devi più combatterlo contro il widget: puoi concentrarti sulla qualità dei risultati che mostri all'utente.