Prima o poi capita a quasi tutti: l'app deve permettere all'utente di scrivere qualcosa di più di una stringa piatta. Note formattate, descrizioni di prodotti, commenti con link e liste puntate, documenti collaborativi. Un TextField non basta più e serve un editor rich text.
In Flutter la soluzione più matura e diffusa è flutter_quill, il porting dell'editor Quill (quello usato da Slack, LinkedIn e molti CMS) sul framework di Google. In questo articolo vediamo come integrarlo, come funziona il formato Delta su cui si basa, come personalizzare la toolbar, salvare i contenuti e renderizzarli in sola lettura.
Perché flutter_quill (e le alternative)
Prima di partire, una panoramica onesta del panorama:
- flutter_quill: il più usato, API stabile, supporto a embed (immagini, video), undo/redo, scorciatoie da tastiera. Ottimo per editor "da form", note e descrizioni.
- super_editor (Flutter Bounty Hunters): più potente e componibile, pensato per editor stile Notion/Medium, ma con curva di apprendimento più ripida.
- appflowy_editor: editor a blocchi, nato dentro AppFlowy; ideale se vuoi un'esperienza "a blocchi" con slash commands.
Se l'obiettivo è "un campo di testo formattabile dentro un form", flutter_quill è quasi sempre la scelta giusta per rapporto sforzo/risultato.
Setup
Aggiungi le dipendenze (questo articolo si riferisce alla serie 11.x, che ha rinominato i vecchi parametri configurations in config):
dependencies:
flutter_quill: ^11.0.0
flutter_quill_extensions: ^11.0.0 # opzionale: immagini e video
flutter_quill ha le proprie stringhe localizzate: vanno registrate nel MaterialApp, altrimenti in debug ottieni un'eccezione sul delegate mancante.
import 'package:flutter_quill/flutter_quill.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
MaterialApp(
localizationsDelegates: const [
FlutterQuillLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: const [Locale('it'), Locale('en')],
home: const EditorPage(),
);
Il primo editor funzionante
Il cuore di tutto è il QuillController: contiene il documento, la selezione corrente e lo stack di undo/redo. La UI è composta da due widget indipendenti, una toolbar e un editor, legati dallo stesso controller.
class EditorPage extends StatefulWidget {
const EditorPage({super.key});
@override
State<EditorPage> createState() => _EditorPageState();
}
class _EditorPageState extends State<EditorPage> {
final QuillController _controller = QuillController.basic();
final FocusNode _focusNode = FocusNode();
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Nuova nota')),
body: Column(
children: [
QuillSimpleToolbar(
controller: _controller,
config: const QuillSimpleToolbarConfig(
showFontFamily: false,
showFontSize: false,
showSearchButton: false,
multiRowsDisplay: false, // toolbar su una riga scrollabile
),
),
const Divider(height: 1),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: QuillEditor.basic(
controller: _controller,
focusNode: _focusNode,
scrollController: _scrollController,
config: const QuillEditorConfig(
placeholder: 'Scrivi qui...',
padding: EdgeInsets.zero,
autoFocus: false,
),
),
),
),
],
),
);
}
}
Con poche righe hai già grassetto, corsivo, liste, titoli, allineamenti, link, undo/redo e scorciatoie da tastiera (Cmd/Ctrl+B, Cmd/Ctrl+Z…) funzionanti su mobile, desktop e web.
Attenzione al
dispose(): il controller va sempre liberato, altrimenti si accumulano listener e il documento resta in memoria. È una delle cause più comuni di leak negli editor tenuti dentro bottom sheet o dialog.
Il formato Delta: perché non è HTML
Quill non salva HTML, ma un formato JSON chiamato Delta: una lista di operazioni che descrivono il contenuto e i suoi attributi.
[
{ "insert": "Titolo\n", "attributes": { "header": 1 } },
{ "insert": "Testo con una parte " },
{ "insert": "in grassetto", "attributes": { "bold": true } },
{ "insert": "\n" }
]
I vantaggi rispetto all'HTML sono concreti:
- è non ambiguo (nessun tag malformato, nessun problema di sanitizzazione all'origine);
- è compatto e facile da diffare;
- è il formato nativo per implementare merge e collaborazione (operational transformation).
Lo svantaggio è che se il backend o un sito web devono mostrare lo stesso contenuto, serve una conversione. Ne parliamo tra poco.
Salvare e ricaricare un documento
Il salvataggio è semplicemente la serializzazione del Delta:
import 'dart:convert';
String serializeDocument(QuillController controller) {
final delta = controller.document.toDelta().toJson();
return jsonEncode(delta);
}
QuillController deserializeDocument(String? json) {
if (json == null || json.isEmpty) return QuillController.basic();
final document = Document.fromJson(jsonDecode(json) as List<dynamic>);
return QuillController(
document: document,
selection: const TextSelection.collapsed(offset: 0),
);
}
Se ti serve anche una versione testuale (per la ricerca full-text o per l'anteprima in una lista), c'è document.toPlainText().
Autosalvataggio con debounce
Il controller espone uno stream di modifiche, perfetto per un salvataggio automatico senza martellare il backend:
StreamSubscription<DocChange>? _sub;
Timer? _debounce;
@override
void initState() {
super.initState();
_sub = _controller.document.changes.listen((event) {
// event.source: local oppure remote
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 800), _save);
});
}
Future<void> _save() async {
final payload = serializeDocument(_controller);
await repository.updateNote(noteId, payload);
}
@override
void dispose() {
_debounce?.cancel();
_sub?.cancel();
_controller.dispose();
super.dispose();
}
Evita invece di chiamare setState() a ogni carattere: l'editor si aggiorna da solo tramite il controller, e un rebuild dell'intera pagina a ogni battuta è lo scenario ideale per far scendere i frame rate.
Modalità sola lettura
Dalla versione 10 la proprietà readOnly vive sul controller, non più sulle configurazioni dell'editor. Per mostrare un contenuto senza permetterne la modifica:
final viewController = QuillController(
document: Document.fromJson(jsonDecode(json)),
selection: const TextSelection.collapsed(offset: 0),
readOnly: true,
);
QuillEditor.basic(
controller: viewController,
config: const QuillEditorConfig(
showCursor: false,
embedBuilders: [],
padding: EdgeInsets.zero,
),
);
Se l'anteprima è dentro una ListView, ricorda di impostare l'editor come non scrollabile (QuillEditorConfig(scrollable: false)) e di avvolgerlo in un contenitore con altezza vincolata, altrimenti ti scontri con i classici errori di layout di due scroll annidati.
Una toolbar su misura
La QuillSimpleToolbar è comoda ma generica. Quando il design lo richiede, puoi comporre la tua toolbar usando i bottoni esposti dal pacchetto:
QuillToolbar(
config: const QuillToolbarConfig(),
child: Row(
children: [
QuillToolbarToggleStyleButton(
controller: _controller,
attribute: Attribute.bold,
),
QuillToolbarToggleStyleButton(
controller: _controller,
attribute: Attribute.italic,
),
QuillToolbarToggleStyleButton(
controller: _controller,
attribute: Attribute.ul,
),
QuillToolbarLinkStyleButton(controller: _controller),
QuillToolbarHistoryButton(controller: _controller, isUndo: true),
QuillToolbarHistoryButton(controller: _controller, isUndo: false),
],
),
);
Niente vieta di usare bottoni completamente custom: tutte le operazioni sono disponibili in modo programmatico sul controller.
// Applica/rimuove il grassetto sulla selezione
_controller.formatSelection(Attribute.bold);
// Trasforma il paragrafo corrente in titolo H2
_controller.formatSelection(Attribute.h2);
// Inserisce testo alla posizione corrente
final index = _controller.selection.baseOffset;
_controller.replaceText(
index,
0,
'@mario.rossi ',
TextSelection.collapsed(offset: index + 13),
);
Questo è il meccanismo da usare per funzionalità come menzioni, slash command o inserimento di template.
Immagini e contenuti embeddati
Gli embed (immagini, video, formule) non sono inclusi nel pacchetto base per non appesantirlo: vivono in flutter_quill_extensions.
QuillEditor.basic(
controller: _controller,
config: QuillEditorConfig(
embedBuilders: FlutterQuillEmbeds.editorBuilders(),
),
);
QuillSimpleToolbar(
controller: _controller,
config: QuillSimpleToolbarConfig(
embedButtons: FlutterQuillEmbeds.toolbarButtons(),
),
);
Un punto di attenzione architetturale: di default l'immagine viene inserita nel Delta come path locale o URL. In produzione quasi sempre vuoi intercettare la selezione, caricare il file sul tuo storage e salvare nel documento l'URL remoto; altrimenti il contenuto sarà illeggibile su un altro dispositivo. Il pacchetto permette di fornire callback personalizzate per la selezione e l'inserimento delle immagini proprio per gestire questo flusso.
Interoperabilità: da Delta a HTML e ritorno
Se il contenuto deve essere mostrato anche su un sito, in una email o in un PDF, servono due conversioni:
- Delta → HTML:
vsc_quill_delta_to_html, porting della libreria JS di riferimento, gestisce correttamente liste annidate e attributi inline. - HTML → Delta:
flutter_quill_delta_from_html, utile per importare contenuti già esistenti nel database.
import 'package:vsc_quill_delta_to_html/vsc_quill_delta_to_html.dart';
String toHtml(QuillController controller) {
final deltaOps = controller.document.toDelta().toJson();
return QuillDeltaToHtmlConverter(
List.castFrom(deltaOps),
ConverterOptions.forEmail(),
).convert();
}
Regola d'oro: la fonte di verità resta il Delta. Converti in HTML solo al momento della visualizzazione esterna, e sanifica sempre l'output lato server prima di iniettarlo in una pagina web.
Consigli pratici e trappole comuni
- Tastiera e insets: su mobile, metti la toolbar in fondo (sopra la tastiera) usando
Scaffold(resizeToAvoidBottomInset: true)e unColumncon l'editor dentro unExpanded. Una toolbar in alto, su schermi piccoli, costringe l'utente a scrollare continuamente. - Documento vuoto: un Delta valido termina sempre con
\n. Per capire se l'utente ha davvero scritto qualcosa, usa_controller.document.toPlainText().trim().isEmpty. - Stili coerenti col tema:
QuillEditorConfig(customStyles: DefaultStyles(...))permette di allineare titoli, paragrafi e blockquote alla tipografia della tua app, dark mode inclusa. - Limitare la formattazione: in molti form non servono 20 bottoni. Disattivare opzioni nella
QuillSimpleToolbarConfigriduce il rischio di contenuti illeggibili e semplifica la conversione in HTML. - Test: nei widget test puoi pilotare l'editor direttamente dal controller (
controller.document.insert(0, 'testo')) e verificare attributi e output serializzato, senza dover simulare tap sulla toolbar. - Web: su Flutter Web la gestione del copia/incolla di contenuti formattati e dell'IME è migliorata molto, ma vale la pena testarla presto se il tuo target principale è il browser.
Conclusioni
flutter_quill risolve in modo pragmatico un problema che da zero costerebbe settimane di lavoro: un editor rich text completo, multipiattaforma e con un formato dati serio alle spalle. Le decisioni importanti non sono nel widget, ma intorno: scegliere il Delta come fonte di verità, gestire l'upload delle immagini verso uno storage remoto, limitare le opzioni di formattazione a quelle realmente necessarie e pianificare da subito la conversione verso HTML se il contenuto deve vivere anche fuori dall'app.
Fatte queste scelte, integrare l'editor diventa la parte più semplice del lavoro.