Costruire schede eleganti in Flutter con Card, ListTile e InkWell

Foto di Angella Meanix su Unsplash

GuidePrincipiante35 min Flutter 3.x

Costruire schede eleganti in Flutter con Card, ListTile e InkWell

Le card sono uno dei mattoni più usati nelle interfacce mobile: le trovi negli elenchi di prodotti, nei feed di notizie, nelle dashboard e nei profili utente.

In Flutter costruire una card è semplicissimo grazie al widget Card, che si occupa di ombra, angoli arrotondati e margini. Combinandolo con ListTile (per il classico layout icona + titolo + sottotitolo) e con InkWell (per il tocco con effetto ripple) ottieni componenti curati con poche righe di codice.

In questo tutorial partiremo da una card minimale e arriveremo a una lista di schede cliccabili con immagine, testo e pulsanti di azione, definendo infine uno stile centralizzato con CardTheme. Non serve nessun pacchetto esterno: tutto è incluso in Material.

Cosa ti serve:

  • Flutter installato e funzionante (flutter doctor senza errori)
  • Un editor come VS Code o Android Studio
  • Conoscenza di base di widget, Column e ListView.builder
  1. 1

    Preparare il progetto e la schermata di base

    Crea un nuovo progetto e sostituisci il contenuto di lib/main.dart con una struttura minima: MaterialApp con Material 3 attivo e uno Scaffold con un ListView in cui inseriremo le nostre schede.

    flutter create card_demo
    cd card_demo
    

    Usiamo un ListView come contenitore (invece di una Column) perché ci permette di scorrere quando le card diventano tante, senza rischiare l'errore di overflow.

    import 'package:flutter/material.dart';
    
    void main() => runApp(const CardDemoApp());
    
    class CardDemoApp extends StatelessWidget {
      const CardDemoApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Card Demo',
          theme: ThemeData(
            useMaterial3: true,
            colorSchemeSeed: Colors.indigo,
          ),
          home: const HomePage(),
        );
      }
    }
    
    class HomePage extends StatelessWidget {
      const HomePage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Le mie schede')),
          body: ListView(
            padding: const EdgeInsets.all(16),
            children: const [
              // qui inseriremo le card
            ],
          ),
        );
      }
    }

    Risultato atteso

    L'app si avvia e mostra una AppBar con il titolo "Le mie schede" e un'area di contenuto vuota.

  2. 2

    La prima card con ListTile

    Card è un contenitore con un solo figlio: gli passiamo un ListTile, il widget pensato per righe di contenuto composte da:

    • leading: widget a sinistra (icona, avatar, immagine)
    • title: testo principale
    • subtitle: testo secondario
    • trailing: widget a destra (freccia, switch, icona)

    Inserisci il codice seguente tra i children del ListView (ricordati di togliere il const dalla lista se aggiungi widget non costanti).

    Card(
      child: ListTile(
        leading: const Icon(Icons.wb_sunny_outlined),
        title: const Text('Meteo di oggi'),
        subtitle: const Text('Sereno, 24°C'),
        trailing: const Icon(Icons.chevron_right),
      ),
    ),
    const SizedBox(height: 12),
    Card(
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: Colors.indigo.shade100,
          child: const Text('MR'),
        ),
        title: const Text('Mario Rossi'),
        subtitle: const Text('mario.rossi@example.com'),
        trailing: IconButton(
          icon: const Icon(Icons.more_vert),
          onPressed: () {},
        ),
      ),
    ),

    Risultato atteso

    Due schede con ombra leggera e angoli arrotondati: la prima con icona e freccia, la seconda con avatar e menu.

  3. 3

    Personalizzare aspetto e margini della card

    Card espone alcune proprietà chiave:

    • elevation: intensità dell'ombra (0 = piatta)
    • color / surfaceTintColor: colore di sfondo e tinta Material 3
    • shape: forma e raggio degli angoli
    • margin: spazio esterno alla card (di default 4 px su tutti i lati)
    • clipBehavior: taglia il figlio seguendo la forma della card, indispensabile quando dentro c'è un'immagine

    Un errore comune è cercare il padding interno: la Card non ne ha. Lo spazio interno lo aggiungi tu con un Padding intorno al contenuto.

    Card(
      elevation: 4,
      margin: EdgeInsets.zero,
      clipBehavior: Clip.antiAlias,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(20),
      ),
      child: const Padding(
        padding: EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Abbonamento Pro',
              style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 8),
            Text('Accesso illimitato a tutti i corsi, senza pubblicità.'),
          ],
        ),
      ),
    ),

    Risultato atteso

    Una scheda con angoli molto arrotondati, ombra più marcata e testo distanziato di 20 px dai bordi.

  4. 4

    Rendere la card cliccabile con InkWell

    Per far reagire la card al tocco hai due strade:

    1. usare ListTile(onTap: ...) quando il contenuto è un ListTile;
    2. avvolgere il contenuto in un InkWell quando la card è composita.

    L'InkWell va dentro la Card, non fuori: così l'effetto ripple resta confinato nell'area della scheda. Per farlo tagliare correttamente agli angoli imposta clipBehavior: Clip.antiAlias sulla Card e lo stesso borderRadius sull'InkWell.

    Attenzione: se usi GestureDetector invece di InkWell il tocco funziona ma non vedrai nessuna animazione di feedback.

    Card(
      clipBehavior: Clip.antiAlias,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
      ),
      child: InkWell(
        borderRadius: BorderRadius.circular(16),
        onTap: () {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('Scheda selezionata!')),
          );
        },
        child: const Padding(
          padding: EdgeInsets.all(16),
          child: Row(
            children: [
              Icon(Icons.download_outlined, size: 32),
              SizedBox(width: 16),
              Expanded(
                child: Text('Scarica il report mensile in formato PDF'),
              ),
              Icon(Icons.chevron_right),
            ],
          ),
        ),
      ),
    ),

    Risultato atteso

    Toccando la scheda compare l'effetto ripple che si ferma ai bordi arrotondati e in basso appare una SnackBar.

  5. 5

    Una card composita con immagine, testo e azioni

    Il pattern più diffuso è la card "verticale": immagine in alto, testo al centro, pulsanti in basso. Si costruisce con una Column dentro la Card.

    Punti da ricordare:

    • l'immagine deve stare come primo figlio della Column, senza Padding, per arrivare a filo dei bordi;
    • AspectRatio (o SizedBox(height: ...)) evita che l'immagine cambi altezza durante il caricamento;
    • BoxFit.cover riempie lo spazio senza deformare la foto;
    • per allineare i bottoni a destra usa Align oppure Row(mainAxisAlignment: MainAxisAlignment.end).

    Qui usiamo un'immagine di rete: ricorda che su Android serve il permesso INTERNET (già presente di default nei progetti Flutter).

    class ArticoloCard extends StatelessWidget {
      const ArticoloCard({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Card(
          clipBehavior: Clip.antiAlias,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              AspectRatio(
                aspectRatio: 16 / 9,
                child: Image.network(
                  'https://picsum.photos/800/450',
                  fit: BoxFit.cover,
                  loadingBuilder: (context, child, progress) {
                    if (progress == null) return child;
                    return const Center(child: CircularProgressIndicator());
                  },
                ),
              ),
              Padding(
                padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Weekend in montagna',
                      style: Theme.of(context).textTheme.titleLarge,
                    ),
                    const SizedBox(height: 8),
                    Text(
                      'Tre itinerari facili da percorrere in giornata, '
                      'con partenza a meno di un\'ora dalla città.',
                      style: Theme.of(context).textTheme.bodyMedium,
                    ),
                  ],
                ),
              ),
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 8),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.end,
                  children: [
                    TextButton(onPressed: () {}, child: const Text('Condividi')),
                    const SizedBox(width: 8),
                    FilledButton(onPressed: () {}, child: const Text('Leggi')),
                  ],
                ),
              ),
              const SizedBox(height: 8),
            ],
          ),
        );
      }
    }

    Risultato atteso

    Una scheda con immagine a filo dei bordi arrotondati, titolo, descrizione e due pulsanti allineati a destra.

  6. 6

    Generare una lista di card da un modello dati

    Nelle app reali le card non si scrivono a mano una per una: si generano da una lista di dati con ListView.builder.

    Creiamo una piccola classe Corso, una lista di esempio e un widget CorsoCard riutilizzabile che riceve il modello come parametro. Estrarre la card in un widget separato (invece di usare un metodo _buildCard()) migliora la leggibilità e le performance, perché Flutter può ricostruire solo il widget che cambia.

    class Corso {
      final String titolo;
      final String docente;
      final int lezioni;
      final IconData icona;
    
      const Corso({
        required this.titolo,
        required this.docente,
        required this.lezioni,
        required this.icona,
      });
    }
    
    const corsi = <Corso>[
      Corso(titolo: 'Flutter base', docente: 'A. Bianchi', lezioni: 12, icona: Icons.phone_android),
      Corso(titolo: 'Dart avanzato', docente: 'L. Verdi', lezioni: 8, icona: Icons.code),
      Corso(titolo: 'UI e animazioni', docente: 'S. Neri', lezioni: 15, icona: Icons.animation),
    ];
    
    class CorsiPage extends StatelessWidget {
      const CorsiPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Corsi')),
          body: ListView.builder(
            padding: const EdgeInsets.all(16),
            itemCount: corsi.length,
            itemBuilder: (context, index) => CorsoCard(corso: corsi[index]),
          ),
        );
      }
    }
    
    class CorsoCard extends StatelessWidget {
      const CorsoCard({super.key, required this.corso});
    
      final Corso corso;
    
      @override
      Widget build(BuildContext context) {
        return Card(
          margin: const EdgeInsets.only(bottom: 12),
          clipBehavior: Clip.antiAlias,
          child: ListTile(
            contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            leading: Icon(corso.icona, size: 32),
            title: Text(corso.titolo),
            subtitle: Text('${corso.docente} · ${corso.lezioni} lezioni'),
            trailing: const Icon(Icons.chevron_right),
            onTap: () {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text('Apro "${corso.titolo}"')),
              );
            },
          ),
        );
      }
    }

    Risultato atteso

    Una lista scorrevole di tre schede identiche nello stile, ognuna cliccabile e con i dati del corso corrispondente.

  7. 7

    Centralizzare lo stile con CardTheme

    Ripetere elevation, shape e margin su ogni Card porta a incoerenze. La soluzione è definire un CardTheme (in Flutter recenti CardThemeData) nel ThemeData: tutte le Card dell'app lo erediteranno automaticamente.

    Dopo questa modifica puoi rimuovere le proprietà di stile dalle singole card e lasciare solo il contenuto. Se una scheda specifica ha bisogno di un aspetto diverso, basta impostare la proprietà direttamente su quella Card: il valore locale vince sempre sul tema.

    Bonus: con Card.outlined (o Card.filled, disponibili in Material 3) ottieni le varianti bordata e piena senza scrivere uno shape personalizzato.

    MaterialApp(
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: Colors.indigo,
        cardTheme: CardThemeData(
          elevation: 2,
          margin: const EdgeInsets.only(bottom: 12),
          clipBehavior: Clip.antiAlias,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(16),
          ),
        ),
      ),
      home: const CorsiPage(),
    );
    
    // Ora nel widget basta scrivere:
    // Card(child: ListTile(...));
    //
    // Varianti Material 3 pronte all'uso:
    // Card.outlined(child: ...)
    // Card.filled(child: ...)

    Risultato atteso

    Tutte le card dell'app hanno lo stesso raggio, la stessa ombra e la stessa spaziatura, definiti in un solo punto del codice.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!