[{"data":1,"prerenderedAt":27},["ShallowReactive",2],{"articolo-ricerca-in-app-in-flutter-con-searchanchor-suggerimenti-debounce-e-cronologia":3,"comments-article-ricerca-in-app-in-flutter-con-searchanchor-suggerimenti-debounce-e-cronologia":26},{"id":4,"title":5,"slug":6,"excerpt":7,"body":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"status":16,"published_at":17,"meta_title":18,"meta_description":19,"category":20,"author":24},90,"Ricerca in-app in Flutter con SearchAnchor: suggerimenti, debounce e cronologia","ricerca-in-app-in-flutter-con-searchanchor-suggerimenti-debounce-e-cronologia","Material 3 porta SearchAnchor e SearchBar: una guida pratica per costruire una ricerca in-app moderna, con suggerimenti asincroni, debounce delle chiamate di rete, cronologia persistente e view personalizzata.","## Perché SearchAnchor\n\nPer 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.\n\nCon 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).\n\n`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.\n\n## Il caso più semplice: SearchAnchor.bar\n\nIl costruttore `SearchAnchor.bar` crea in un colpo solo la barra e la view associata:\n\n```dart\nclass SimpleSearch extends StatelessWidget {\n  const SimpleSearch({super.key, required this.items});\n\n  final List\u003CString> items;\n\n  @override\n  Widget build(BuildContext context) {\n    return SearchAnchor.bar(\n      barHintText: 'Cerca un prodotto',\n      barLeading: const Icon(Icons.search),\n      suggestionsBuilder: (context, controller) {\n        final query = controller.text.toLowerCase();\n        final results = items\n            .where((item) => item.toLowerCase().contains(query))\n            .take(10);\n\n        return results.map(\n          (item) => ListTile(\n            title: Text(item),\n            onTap: () {\n              \u002F\u002F Chiude la view e scrive il valore nella barra\n              controller.closeView(item);\n            },\n          ),\n        );\n      },\n    );\n  }\n}\n```\n\nTre concetti da fissare subito:\n\n- **`suggestionsBuilder`** viene invocato ogni volta che il testo cambia (e all'apertura della view). Restituisce un `Iterable\u003CWidget>` oppure un `Future\u003CIterable\u003CWidget>>`: il supporto asincrono è nativo.\n- **`SearchController`** estende `TextEditingController` e aggiunge `openView()`, `closeView(String? text)` e `isOpen`. È il ponte fra barra e view.\n- Il widget della view viene ricostruito automaticamente: non serve gestire uno `State` per la lista dei suggerimenti.\n\n## Separare barra e view con SearchAnchor\n\nQuando la barra deve avere un aspetto diverso dal default — per esempio un semplice `IconButton` nella `AppBar` — si usa il costruttore standard con `builder`:\n\n```dart\nSearchAnchor(\n  isFullScreen: MediaQuery.sizeOf(context).width \u003C 600,\n  viewHintText: 'Cerca fra gli ordini',\n  builder: (context, controller) {\n    return IconButton(\n      icon: const Icon(Icons.search),\n      tooltip: 'Cerca',\n      onPressed: controller.openView,\n    );\n  },\n  suggestionsBuilder: (context, controller) async {\n    \u002F\u002F ...\n    return const \u003CWidget>[];\n  },\n);\n```\n\n`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.\n\n## Suggerimenti da rete: il problema del debounce\n\nSe 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.\n\nUna piccola utility riusabile:\n\n```dart\nimport 'dart:async';\n\nclass Debouncer {\n  Debouncer({this.duration = const Duration(milliseconds: 350)});\n\n  final Duration duration;\n  Timer? _timer;\n  Completer\u003Cdynamic>? _pending;\n\n  \u002F\u002F\u002F Esegue [action] dopo [duration]. Se arriva una nuova chiamata prima\n  \u002F\u002F\u002F dello scadere del timer, la precedente viene annullata e la sua\n  \u002F\u002F\u002F Future completa con null.\n  Future\u003CT?> run\u003CT>(Future\u003CT> Function() action) {\n    _timer?.cancel();\n    if (_pending != null && !_pending!.isCompleted) {\n      _pending!.complete(null);\n    }\n\n    final completer = Completer\u003CT?>();\n    _pending = completer;\n\n    _timer = Timer(duration, () async {\n      if (completer.isCompleted) return;\n      try {\n        completer.complete(await action());\n      } catch (error, stack) {\n        if (!completer.isCompleted) completer.completeError(error, stack);\n      }\n    });\n\n    return completer.future;\n  }\n\n  void dispose() {\n    _timer?.cancel();\n    if (_pending != null && !_pending!.isCompleted) {\n      _pending!.complete(null);\n    }\n  }\n}\n```\n\nIl 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:\n\n```dart\nclass ProductSearch extends StatefulWidget {\n  const ProductSearch({super.key, required this.repository});\n\n  final ProductRepository repository;\n\n  @override\n  State\u003CProductSearch> createState() => _ProductSearchState();\n}\n\nclass _ProductSearchState extends State\u003CProductSearch> {\n  final _debouncer = Debouncer();\n  final _searchController = SearchController();\n  List\u003CProduct> _lastResults = const [];\n\n  @override\n  void dispose() {\n    _debouncer.dispose();\n    _searchController.dispose();\n    super.dispose();\n  }\n\n  Future\u003CIterable\u003CWidget>> _buildSuggestions(\n    BuildContext context,\n    SearchController controller,\n  ) async {\n    final query = controller.text.trim();\n    if (query.length \u003C 2) {\n      return const [\n        ListTile(\n          leading: Icon(Icons.info_outline),\n          title: Text('Digita almeno 2 caratteri'),\n        ),\n      ];\n    }\n\n    List\u003CProduct>? results;\n    try {\n      results = await _debouncer.run(() => widget.repository.search(query));\n    } catch (_) {\n      return const [\n        ListTile(\n          leading: Icon(Icons.error_outline),\n          title: Text('Errore durante la ricerca'),\n        ),\n      ];\n    }\n\n    \u002F\u002F null = richiesta annullata da una digitazione successiva\n    if (results == null) {\n      return _lastResults.map(_tileFor);\n    }\n\n    _lastResults = results;\n    if (results.isEmpty) {\n      return const [ListTile(title: Text('Nessun risultato'))];\n    }\n    return results.map(_tileFor);\n  }\n\n  Widget _tileFor(Product product) {\n    return ListTile(\n      key: ValueKey(product.id),\n      leading: const Icon(Icons.inventory_2_outlined),\n      title: Text(product.name),\n      subtitle: Text(product.category),\n      onTap: () {\n        _searchController.closeView(product.name);\n        Navigator.of(context).pushNamed('\u002Fproduct\u002F${product.id}');\n      },\n    );\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return SearchAnchor.bar(\n      searchController: _searchController,\n      barHintText: 'Cerca prodotti',\n      suggestionsBuilder: _buildSuggestions,\n    );\n  }\n}\n```\n\nNota il `key: ValueKey(product.id)` sulle tile: aiuta Flutter a riusare gli elementi quando la lista cambia parzialmente, riducendo i rebuild inutili.\n\n## Cronologia delle ricerche\n\nUna ricerca ben fatta mostra qualcosa anche a campo vuoto. La cronologia recente è il contenuto più utile: la persistiamo con `shared_preferences`.\n\n```dart\nclass SearchHistory {\n  SearchHistory(this._prefs);\n\n  static const _key = 'search_history';\n  static const _maxEntries = 8;\n  final SharedPreferences _prefs;\n\n  List\u003CString> get entries => _prefs.getStringList(_key) ?? const [];\n\n  Future\u003Cvoid> add(String query) async {\n    final value = query.trim();\n    if (value.isEmpty) return;\n\n    final updated = [value, ...entries.where((e) => e != value)]\n        .take(_maxEntries)\n        .toList();\n    await _prefs.setStringList(_key, updated);\n  }\n\n  Future\u003Cvoid> remove(String query) async {\n    await _prefs.setStringList(_key, entries.where((e) => e != query).toList());\n  }\n}\n```\n\nE nel `suggestionsBuilder`, quando la query è vuota:\n\n```dart\nIterable\u003CWidget> _buildHistory(SearchController controller) {\n  final entries = _history.entries;\n  if (entries.isEmpty) {\n    return const [\n      Padding(\n        padding: EdgeInsets.all(16),\n        child: Text('Le ricerche recenti appariranno qui'),\n      ),\n    ];\n  }\n\n  return entries.map(\n    (query) => ListTile(\n      leading: const Icon(Icons.history),\n      title: Text(query),\n      trailing: IconButton(\n        icon: const Icon(Icons.close),\n        tooltip: 'Rimuovi dalla cronologia',\n        onPressed: () async {\n          await _history.remove(query);\n          \u002F\u002F Forza la rigenerazione dei suggerimenti\n          setState(() {});\n        },\n      ),\n      onTap: () {\n        controller.text = query;\n        \u002F\u002F Sposta il cursore in fondo\n        controller.selection =\n            TextSelection.collapsed(offset: query.length);\n      },\n    ),\n  );\n}\n```\n\nQuando l'utente conferma una ricerca (tap su un risultato oppure `onSubmitted` della barra) chiamiamo `_history.add(query)`.\n\n## Evidenziare il testo cercato\n\nUn dettaglio che fa percepire la ricerca come \"professionale\" è l'highlight della porzione di testo corrispondente alla query:\n\n```dart\nWidget highlight(String text, String query, TextStyle? base) {\n  final index = text.toLowerCase().indexOf(query.toLowerCase());\n  if (query.isEmpty || index \u003C 0) return Text(text, style: base);\n\n  return Text.rich(\n    TextSpan(\n      style: base,\n      children: [\n        TextSpan(text: text.substring(0, index)),\n        TextSpan(\n          text: text.substring(index, index + query.length),\n          style: const TextStyle(fontWeight: FontWeight.bold),\n        ),\n        TextSpan(text: text.substring(index + query.length)),\n      ],\n    ),\n  );\n}\n```\n\n## Personalizzare la view\n\n`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`:\n\n```dart\nSearchAnchor(\n  viewBuilder: (suggestions) {\n    return GridView.count(\n      crossAxisCount: 2,\n      padding: const EdgeInsets.all(12),\n      childAspectRatio: 3,\n      children: suggestions.toList(),\n    );\n  },\n  builder: (context, controller) => SearchBar(\n    controller: controller,\n    hintText: 'Cerca',\n    onTap: controller.openView,\n    onChanged: (_) => controller.openView(),\n    leading: const Icon(Icons.search),\n  ),\n  suggestionsBuilder: _buildSuggestions,\n);\n```\n\nAttenzione 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.\n\n## Accorgimenti pratici\n\n- **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é.\n- **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.\n- **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.\n- **Cache dei risultati**: una semplice `Map\u003CString, List\u003CProduct>>` in memoria evita di richiamare l'API per query già viste, utile quando l'utente cancella caratteri.\n- **Accessibilità**: `SearchBar` e `SearchAnchor` espongono già la semantica corretta, ma ricordati di valorizzare `barHintText`\u002F`viewHintText` e i `tooltip` delle icone.\n- **Localizzazione**: il pulsante \"indietro\" e le etichette della view usano `MaterialLocalizations`; assicurati che `flutter_localizations` sia configurato, altrimenti resteranno in inglese.\n\n## Testare la ricerca\n\nLa view di `SearchAnchor` è un overlay, quindi nei widget test va aperta esplicitamente prima di verificarne il contenuto:\n\n```dart\ntestWidgets('mostra i suggerimenti filtrati', (tester) async {\n  await tester.pumpWidget(MaterialApp(\n    home: Scaffold(body: SimpleSearch(items: const ['Mela', 'Melone', 'Pera'])),\n  ));\n\n  await tester.tap(find.byType(SearchBar));\n  await tester.pumpAndSettle();\n\n  await tester.enterText(find.byType(TextField).last, 'mel');\n  await tester.pumpAndSettle();\n\n  expect(find.text('Mela'), findsOneWidget);\n  expect(find.text('Melone'), findsOneWidget);\n  expect(find.text('Pera'), findsNothing);\n});\n```\n\nSe hai introdotto il debounce, ricorda di far avanzare il tempo con `await tester.pump(const Duration(milliseconds: 400))` prima delle asserzioni.\n\n## Conclusione\n\n`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.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Farticles\u002Fdc471f8a-6b24-4491-9ba8-af546eb9b331.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1590345213370-caf4f5515bc6?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3ODg3NTM3MDl8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"Rich Smith","https:\u002F\u002Funsplash.com\u002F@richwilliamsmith","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fa-close-up-of-a-person-holding-a-cell-phone-qezGbhOzQyg",null,"published","2026-09-07T04:01:50+00:00","Ricerca in Flutter con SearchAnchor e SearchBar","Guida pratica alla ricerca in-app in Flutter con SearchAnchor e SearchBar di Material 3: suggerimenti asincroni, debounce, cronologia e view personalizzata.",{"id":21,"name":22,"slug":23},1,"Guide","guide",{"id":21,"name":25},"Flutter Bot",[],1789120580046]