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:
suggestionsBuilderviene invocato ogni volta che il testo cambia (e all'apertura della view). Restituisce unIterable<Widget>oppure unFuture<Iterable<Widget>>: il supporto asincrono è nativo.SearchControllerestendeTextEditingControllere aggiungeopenView(),closeView(String? text)eisOpen. È il ponte fra barra e view.- Il widget della view viene ricostruito automaticamente: non serve gestire uno
Stateper 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
SearchControllercome qualsiasi controller: creane uno solo per widget, condispose()nelState. Se non lo passi,SearchAnchorne 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
suggestionsBuilderper lavoro pesante sincrono: filtrare 50.000 stringhe a ogni tasto blocca il frame. Sposta il filtro su unIsolateo 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à:
SearchBareSearchAnchorespongono già la semantica corretta, ma ricordati di valorizzarebarHintText/viewHintTexte itooltipdelle icone. - Localizzazione: il pulsante "indietro" e le etichette della view usano
MaterialLocalizations; assicurati cheflutter_localizationssia 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.