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 un Column con l'editor dentro un Expanded. 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 QuillSimpleToolbarConfig riduce 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.