Controlli di selezione in Flutter: Checkbox, Radio, Switch e Slider

Foto di Aaron Mrvelj su Unsplash

GuidePrincipiante35 min Flutter 3.x

Controlli di selezione in Flutter: Checkbox, Radio, Switch e Slider

Quasi ogni app ha una schermata di preferenze: attivare le notifiche, accettare i termini, scegliere un livello di qualità, regolare il volume. Flutter mette a disposizione una famiglia di widget pensati esattamente per questo: Checkbox, Switch, Radio e Slider, insieme alle loro varianti "pronte all'uso" CheckboxListTile, SwitchListTile e RadioListTile.

Tutti questi widget hanno una caratteristica in comune: sono controllati. Non memorizzano internamente il valore selezionato, ma lo ricevono dall'esterno (value) e notificano i cambiamenti con una callback (onChanged). Sta a te salvare il nuovo valore nello stato e ricostruire la UI: è il classico pattern setState dei StatefulWidget.

In questo tutorial costruiremo una schermata Impostazioni completa:

  • una checkbox per accettare i termini;
  • alcuni switch per le notifiche;
  • un gruppo di radio button per la qualità di download;
  • uno slider per il volume e un RangeSlider per una fascia di prezzo;
  • un riepilogo finale dei valori scelti.

Al termine avrai chiaro il funzionamento del ciclo valore → callback → setState → rebuild, che è il cuore di qualsiasi form in Flutter.

  1. 1

    Creare la schermata Impostazioni con uno StatefulWidget

    Partiamo da un progetto nuovo (flutter create controlli_demo) e sostituiamo il contenuto di lib/main.dart.

    Tutti i controlli di selezione sono stateless dal punto di vista del dato: mostrano ciò che gli passi e ti avvisano quando l'utente interagisce. Per questo la schermata che li contiene deve essere uno StatefulWidget, dove dichiariamo le variabili che conservano i valori.

    Iniziamo con lo scheletro: MaterialApp, Scaffold, una AppBar e una ListView (che ci permette di scorrere quando gli elementi cresceranno, evitando il classico errore di overflow).

    import 'package:flutter/material.dart';
    
    void main() => runApp(const MyApp());
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Controlli di selezione',
          theme: ThemeData(
            colorSchemeSeed: Colors.indigo,
            useMaterial3: true,
          ),
          home: const ImpostazioniPage(),
        );
      }
    }
    
    class ImpostazioniPage extends StatefulWidget {
      const ImpostazioniPage({super.key});
    
      @override
      State<ImpostazioniPage> createState() => _ImpostazioniPageState();
    }
    
    class _ImpostazioniPageState extends State<ImpostazioniPage> {
      // Qui aggiungeremo le variabili di stato nei prossimi passi.
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Impostazioni')),
          body: ListView(
            padding: const EdgeInsets.symmetric(vertical: 8),
            children: const [
              ListTile(title: Text('Contenuto in arrivo...')),
            ],
          ),
        );
      }
    }

    Risultato atteso

    L'app si avvia mostrando una schermata con AppBar 'Impostazioni' e una lista vuota pronta ad accogliere i controlli.

  2. 2

    Checkbox e CheckboxListTile: le scelte booleane

    La Checkbox è il controllo più semplice: ha un value di tipo bool? e una callback onChanged.

    Regole da ricordare:

    • se passi onChanged: null la checkbox appare disabilitata;
    • senza setState dentro onChanged la spunta non cambia visivamente, perché il widget disegna sempre il valore che gli passi;
    • CheckboxListTile unisce checkbox, titolo e sottotitolo in una riga cliccabile per intero: è la scelta migliore in una lista di impostazioni.

    Aggiungiamo una checkbox "nuda" affiancata da un testo e una CheckboxListTile per l'accettazione dei termini.

    // Dentro _ImpostazioniPageState
    bool _accettaTermini = false;
    bool _ricordami = true;
    
    // ...dentro il build, come figli della ListView:
    Row(
      children: [
        Checkbox(
          value: _ricordami,
          onChanged: (bool? nuovo) {
            setState(() => _ricordami = nuovo ?? false);
          },
        ),
        const Text('Ricordami su questo dispositivo'),
      ],
    ),
    CheckboxListTile(
      value: _accettaTermini,
      onChanged: (bool? nuovo) {
        setState(() => _accettaTermini = nuovo ?? false);
      },
      title: const Text('Accetto i termini di servizio'),
      subtitle: const Text('Obbligatorio per continuare'),
      controlAffinity: ListTileControlAffinity.leading, // spunta a sinistra
      secondary: const Icon(Icons.description_outlined),
    ),

    Risultato atteso

    Toccando la riga dei termini o la checkbox la spunta si attiva e disattiva correttamente.

  3. 3

    Switch e SwitchListTile: attivare e disattivare funzioni

    Lo Switch esprime lo stesso concetto della checkbox (vero/falso) ma con una semantica diversa: indica un'azione che ha effetto immediato, come accendere le notifiche. La checkbox, invece, si usa di solito dentro un form che verrà confermato con un pulsante.

    A differenza di Checkbox, il value di Switch è un bool non nullable.

    Un dettaglio utile: possiamo disabilitare uno switch dipendente passando onChanged: null. Nell'esempio, gli switch di email e push restano spenti e non toccabili finché le notifiche generali sono disattivate.

    bool _notifiche = true;
    bool _notificheEmail = false;
    bool _notifichePush = true;
    
    // ...nella ListView:
    SwitchListTile(
      value: _notifiche,
      onChanged: (bool nuovo) => setState(() => _notifiche = nuovo),
      title: const Text('Notifiche'),
      subtitle: const Text('Attiva o disattiva tutte le notifiche'),
      secondary: const Icon(Icons.notifications_active_outlined),
    ),
    SwitchListTile(
      value: _notificheEmail,
      // null => controllo disabilitato
      onChanged: _notifiche
          ? (bool nuovo) => setState(() => _notificheEmail = nuovo)
          : null,
      title: const Text('Email'),
    ),
    SwitchListTile(
      value: _notifichePush,
      onChanged: _notifiche
          ? (bool nuovo) => setState(() => _notifichePush = nuovo)
          : null,
      title: const Text('Push'),
    ),

    Risultato atteso

    Spegnendo lo switch 'Notifiche' i due switch sottostanti diventano grigi e non rispondono al tocco.

  4. 4

    Radio e RadioListTile: una sola scelta tra tante

    I radio button servono quando le opzioni sono mutuamente esclusive. Il meccanismo è leggermente diverso: ogni Radio ha un proprio value (l'opzione che rappresenta) e tutti condividono lo stesso groupValue (l'opzione attualmente selezionata). Quando value == groupValue il pallino appare pieno.

    Per rendere il codice leggibile e sicuro usiamo un enum invece di stringhe: l'analizzatore ci avviserà se dimentichiamo un caso.

    Nota: dalle versioni più recenti di Flutter esiste anche il widget RadioGroup, che raggruppa i radio e gestisce la selezione al posto tuo. L'API con groupValue mostrata qui resta la più diffusa e funziona su tutte le versioni 3.x.

    enum Qualita { bassa, media, alta }
    
    extension QualitaLabel on Qualita {
      String get etichetta => switch (this) {
            Qualita.bassa => 'Bassa (risparmio dati)',
            Qualita.media => 'Media (consigliata)',
            Qualita.alta => 'Alta (file più pesanti)',
          };
    }
    
    // Dentro lo State:
    Qualita _qualita = Qualita.media;
    
    // ...nella ListView:
    const Padding(
      padding: EdgeInsets.fromLTRB(16, 16, 16, 4),
      child: Text('Qualità download',
          style: TextStyle(fontWeight: FontWeight.bold)),
    ),
    ...Qualita.values.map(
      (opzione) => RadioListTile<Qualita>(
        value: opzione,
        groupValue: _qualita,
        onChanged: (Qualita? nuovo) {
          if (nuovo != null) setState(() => _qualita = nuovo);
        },
        title: Text(opzione.etichetta),
        dense: true,
      ),
    ),

    Risultato atteso

    Selezionando una qualità il pallino si sposta: solo un'opzione per volta risulta attiva.

  5. 5

    Slider e RangeSlider: valori numerici continui o a scatti

    Lo Slider gestisce un double compreso tra min e max. Proprietà chiave:

    • divisions: divide la barra in scatti discreti (senza, il movimento è continuo);
    • label: mostra un fumetto con il valore durante il trascinamento (visibile solo se divisions è impostato);
    • onChangeEnd: utile per salvare il valore una sola volta, quando l'utente rilascia il dito, evitando decine di scritture su disco o chiamate di rete.

    Il RangeSlider funziona allo stesso modo ma lavora con un oggetto RangeValues (con start e end) per selezionare un intervallo.

    double _volume = 60;
    RangeValues _prezzo = const RangeValues(20, 80);
    
    // ...nella ListView:
    ListTile(
      leading: const Icon(Icons.volume_up_outlined),
      title: Text('Volume: ${_volume.round()}%'),
      subtitle: Slider(
        value: _volume,
        min: 0,
        max: 100,
        divisions: 20, // scatti da 5 in 5
        label: '${_volume.round()}%',
        onChanged: (double nuovo) => setState(() => _volume = nuovo),
        onChangeEnd: (double finale) {
          debugPrint('Salvo il volume: $finale');
        },
      ),
    ),
    ListTile(
      leading: const Icon(Icons.euro),
      title: Text(
        'Prezzo: ${_prezzo.start.round()}€ - ${_prezzo.end.round()}€',
      ),
      subtitle: RangeSlider(
        values: _prezzo,
        min: 0,
        max: 200,
        divisions: 40,
        labels: RangeLabels(
          '${_prezzo.start.round()}€',
          '${_prezzo.end.round()}€',
        ),
        onChanged: (RangeValues nuovi) => setState(() => _prezzo = nuovi),
      ),
    ),

    Risultato atteso

    Trascinando gli slider il titolo si aggiorna in tempo reale e nella console appare il log al rilascio.

  6. 6

    Raccogliere i valori e mostrare un riepilogo

    Ora che tutti i controlli scrivono nello stesso State, leggere i valori è immediato: sono semplici variabili Dart.

    Aggiungiamo in fondo alla lista un pulsante "Salva" che mostra un SnackBar con il riepilogo. Il pulsante è attivo solo se i termini sono stati accettati: passando onPressed: null Flutter disabilita automaticamente il bottone e ne applica lo stile grigio.

    Questo è anche il punto in cui, in un'app reale, salveresti i dati (per esempio con shared_preferences) o li invieresti al backend.

    String _riepilogo() {
      final buffer = StringBuffer()
        ..writeln('Ricordami: ${_ricordami ? 'sì' : 'no'}')
        ..writeln('Notifiche: ${_notifiche ? 'attive' : 'spente'}')
        ..writeln('Qualità: ${_qualita.etichetta}')
        ..writeln('Volume: ${_volume.round()}%')
        ..write('Prezzo: ${_prezzo.start.round()}-${_prezzo.end.round()}€');
      return buffer.toString();
    }
    
    // ...in fondo alla ListView:
    const Divider(),
    Padding(
      padding: const EdgeInsets.all(16),
      child: FilledButton.icon(
        onPressed: _accettaTermini
            ? () {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text(_riepilogo())),
                );
              }
            : null,
        icon: const Icon(Icons.save_outlined),
        label: const Text('Salva impostazioni'),
      ),
    ),

    Risultato atteso

    Il pulsante 'Salva impostazioni' è grigio finché non si accettano i termini; premendolo compare uno SnackBar con tutti i valori scelti.

  7. 7

    Personalizzare stile e accessibilità dal tema

    Invece di ripetere activeColor su ogni widget, conviene definire lo stile una volta sola nel ThemeData. Flutter offre CheckboxThemeData, SwitchThemeData, RadioThemeData e SliderThemeData; le proprietà accettano WidgetStateProperty (chiamata MaterialStateProperty nelle versioni Flutter precedenti alla 3.22) per differenziare lo stato selezionato da quello normale.

    Due accortezze finali:

    • usa ListTile/*ListTile quando puoi: l'area cliccabile più grande migliora l'usabilità sui dispositivi touch;
    • aggiungi un semanticLabel alla Checkbox "nuda" (senza testo associato) per gli screen reader, e ricorda che Slider annuncia automaticamente il valore se imposti label.
    ThemeData(
      useMaterial3: true,
      colorSchemeSeed: Colors.indigo,
      checkboxTheme: CheckboxThemeData(
        fillColor: WidgetStateProperty.resolveWith((states) {
          if (states.contains(WidgetState.selected)) return Colors.indigo;
          return null; // usa il default del tema
        }),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(4),
        ),
      ),
      switchTheme: SwitchThemeData(
        thumbColor: WidgetStateProperty.resolveWith((states) =>
            states.contains(WidgetState.selected) ? Colors.white : null),
      ),
      sliderTheme: const SliderThemeData(
        showValueIndicator: ShowValueIndicator.always,
      ),
    );
    
    // Checkbox senza etichetta: aggiungi sempre una label semantica
    Checkbox(
      value: _ricordami,
      semanticLabel: 'Ricordami su questo dispositivo',
      onChanged: (v) => setState(() => _ricordami = v ?? false),
    );

    Risultato atteso

    Tutti i controlli condividono lo stesso stile coerente e restano accessibili ai lettori di schermo.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!