Flutter Widgets Fundamentals

Card & ListTile

40 min Lesson 8 of 18

The Card Widget

The Card widget is a Material Design surface that represents a piece of content. It has rounded corners and an elevation shadow by default, making it perfect for displaying grouped information like contacts, products, settings, or articles. Under the hood, a Card is simply a Material widget with some preset styling.

Unlike building a card manually with Container and BoxDecoration, the Card widget automatically follows Material Design guidelines and responds to theme changes.

Basic Card

Card(
  child: Padding(
    padding: const EdgeInsets.all(16),
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        const Text(
          'Simple Card',
          style: TextStyle(
            fontSize: 20,
            fontWeight: FontWeight.bold,
          ),
        ),
        const SizedBox(height: 8),
        const Text(
          'This is a basic card with some content inside.',
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: () {},
          child: const Text('Action'),
        ),
      ],
    ),
  ),
)

Key Card properties:

  • elevation -- The z-coordinate shadow depth. Default is 1.0. Set to 0 for a flat card.
  • color -- Background color of the card. Defaults to the theme’s card color.
  • shadowColor -- Color of the shadow beneath the card.
  • surfaceTintColor -- Tint color applied to the card surface (Material 3).
  • shape -- The shape of the card. Defaults to RoundedRectangleBorder with a 12px radius in Material 3.
  • margin -- Empty space around the card.
  • clipBehavior -- How the card clips its content. Use Clip.antiAlias for images that extend to the edges.

Card Shapes

You can customize the card’s shape using ShapeBorder subclasses:

Card Shape Examples

// Rounded rectangle (default)
Card(
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(16),
  ),
  child: const Padding(
    padding: EdgeInsets.all(16),
    child: Text('Rounded Card'),
  ),
)

// Card with border
Card(
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(12),
    side: const BorderSide(color: Colors.blue, width: 2),
  ),
  child: const Padding(
    padding: EdgeInsets.all(16),
    child: Text('Bordered Card'),
  ),
)

// Stadium shape (pill shape)
Card(
  shape: const StadiumBorder(),
  child: const Padding(
    padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
    child: Text('Stadium Card'),
  ),
)

// Beveled rectangle
Card(
  shape: BeveledRectangleBorder(
    borderRadius: BorderRadius.circular(12),
  ),
  child: const Padding(
    padding: EdgeInsets.all(16),
    child: Text('Beveled Card'),
  ),
)

Card with ClipBehavior

When a card contains images that extend to its edges, you need clipBehavior to ensure the image respects the card’s rounded corners:

Card with Image

Card(
  clipBehavior: Clip.antiAlias,
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(16),
  ),
  elevation: 4,
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Image.network(
        'https://picsum.photos/400/200',
        height: 200,
        width: double.infinity,
        fit: BoxFit.cover,
      ),
      const Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Beautiful Landscape',
              style: TextStyle(
                fontSize: 20,
                fontWeight: FontWeight.bold,
              ),
            ),
            SizedBox(height: 8),
            Text(
              'A stunning view captured during a morning hike.',
              style: TextStyle(color: Colors.grey),
            ),
          ],
        ),
      ),
    ],
  ),
)
Warning: Without clipBehavior: Clip.antiAlias, images inside a Card will overflow past the rounded corners, creating an ugly visual glitch. Always set clipBehavior when using images that touch the card edges.

The ListTile Widget

ListTile is a fixed-height row widget that follows the Material Design list spec. It is designed to display up to three lines of text with optional leading and trailing widgets. ListTile is the standard building block for lists, settings screens, and menus.

Basic ListTile

ListTile(
  leading: const CircleAvatar(
    child: Text('A'),
  ),
  title: const Text('Alice Johnson'),
  subtitle: const Text('Software Developer'),
  trailing: const Icon(Icons.arrow_forward_ios),
  onTap: () {
    debugPrint('Tapped Alice');
  },
)

Key ListTile properties:

  • leading -- A widget displayed at the start (left in LTR). Usually an icon, avatar, or image.
  • title -- The primary text widget. Usually a Text widget.
  • subtitle -- Secondary text displayed below the title.
  • trailing -- A widget displayed at the end (right in LTR). Often an icon, switch, or badge.
  • onTap -- Callback when the tile is tapped. Adds a ripple effect.
  • onLongPress -- Callback for long press.
  • dense -- If true, reduces the tile height and text sizes.
  • selected -- If true, uses the theme’s selected color for text and icons.
  • contentPadding -- The internal padding of the tile.
  • tileColor -- Background color of the tile.
  • selectedTileColor -- Background color when selected is true.
  • isThreeLine -- If true, allows the subtitle to occupy two lines.

ListTile Variants

Flutter provides specialized ListTile variants for common use cases:

ListTile Variations

// Standard ListTile with three lines
ListTile(
  isThreeLine: true,
  leading: const CircleAvatar(
    backgroundImage: NetworkImage(
      'https://picsum.photos/100/100',
    ),
  ),
  title: const Text('Project Update'),
  subtitle: const Text(
    'The latest sprint has been completed successfully. '
    'All tasks were delivered on time.',
    maxLines: 2,
    overflow: TextOverflow.ellipsis,
  ),
  trailing: const Text('2h ago', style: TextStyle(color: Colors.grey)),
)

// Dense ListTile (compact)
const ListTile(
  dense: true,
  leading: Icon(Icons.wifi, size: 20),
  title: Text('Wi-Fi'),
  subtitle: Text('Connected'),
  trailing: Icon(Icons.check, color: Colors.green, size: 20),
)

// Selected ListTile
ListTile(
  selected: true,
  selectedTileColor: Colors.blue[50],
  selectedColor: Colors.blue,
  leading: const Icon(Icons.inbox),
  title: const Text('Inbox'),
  trailing: const Text('12'),
  onTap: () {},
)
Note: When isThreeLine is false (default), the subtitle is limited to one line. When true, the subtitle can span two lines and the tile becomes taller to accommodate the extra text.

The Divider Widget

The Divider widget draws a thin horizontal line, commonly used to separate ListTile items or sections of content:

Divider Usage

Column(
  children: [
    const ListTile(
      leading: Icon(Icons.email),
      title: Text('Email'),
      subtitle: Text('john@example.com'),
    ),
    const Divider(height: 1),
    const ListTile(
      leading: Icon(Icons.phone),
      title: Text('Phone'),
      subtitle: Text('+1 234 567 8900'),
    ),
    const Divider(height: 1),
    const ListTile(
      leading: Icon(Icons.location_on),
      title: Text('Address'),
      subtitle: Text('123 Main Street'),
    ),
  ],
)

// Divider properties
const Divider(
  height: 20,        // Total space (line + space above + below)
  thickness: 2,      // Line thickness
  indent: 16,        // Left indent
  endIndent: 16,     // Right indent
  color: Colors.grey,
)

Combining Card and ListTile

Cards and ListTiles work beautifully together. A common pattern is to wrap ListTiles inside a Card:

Card with ListTiles

Card(
  margin: const EdgeInsets.all(16),
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(12),
  ),
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      ListTile(
        leading: const CircleAvatar(
          backgroundColor: Colors.blue,
          child: Icon(Icons.person, color: Colors.white),
        ),
        title: const Text('John Doe'),
        subtitle: const Text('Premium Member'),
        trailing: IconButton(
          icon: const Icon(Icons.edit),
          onPressed: () {},
        ),
      ),
      const Divider(height: 1),
      ListTile(
        leading: const Icon(Icons.email_outlined),
        title: const Text('john.doe@email.com'),
        onTap: () {},
      ),
      ListTile(
        leading: const Icon(Icons.phone_outlined),
        title: const Text('+1 (555) 123-4567'),
        onTap: () {},
      ),
      ListTile(
        leading: const Icon(Icons.location_on_outlined),
        title: const Text('New York, USA'),
        onTap: () {},
      ),
    ],
  ),
)

Practical Example: Contact List

Contact List Screen

class ContactListScreen extends StatelessWidget {
  const ContactListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final contacts = [
      {'name': 'Alice', 'role': 'Designer', 'initial': 'A'},
      {'name': 'Bob', 'role': 'Developer', 'initial': 'B'},
      {'name': 'Charlie', 'role': 'Manager', 'initial': 'C'},
      {'name': 'Diana', 'role': 'QA Lead', 'initial': 'D'},
    ];

    return Scaffold(
      appBar: AppBar(title: const Text('Contacts')),
      body: ListView.separated(
        padding: const EdgeInsets.all(8),
        itemCount: contacts.length,
        separatorBuilder: (context, index) =>
            const Divider(height: 1),
        itemBuilder: (context, index) {
          final contact = contacts[index];
          return ListTile(
            leading: CircleAvatar(
              backgroundColor: Colors.primaries[
                  index % Colors.primaries.length],
              child: Text(
                contact['initial']!,
                style: const TextStyle(color: Colors.white),
              ),
            ),
            title: Text(contact['name']!),
            subtitle: Text(contact['role']!),
            trailing: const Icon(Icons.chevron_right),
            onTap: () {
              debugPrint('Tapped \${contact["name"]}');
            },
          );
        },
      ),
    );
  }
}

Practical Example: Settings Screen

Settings Screen with Cards

class SettingsScreen extends StatefulWidget {
  const SettingsScreen({super.key});

  @override
  State<SettingsScreen> createState() => _SettingsScreenState();
}

class _SettingsScreenState extends State<SettingsScreen> {
  bool _darkMode = false;
  bool _notifications = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Settings')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // Account section
          const Text(
            'Account',
            style: TextStyle(
              fontSize: 14,
              fontWeight: FontWeight.bold,
              color: Colors.grey,
            ),
          ),
          const SizedBox(height: 8),
          Card(
            child: Column(
              children: [
                ListTile(
                  leading: const CircleAvatar(
                    child: Text('J'),
                  ),
                  title: const Text('John Doe'),
                  subtitle: const Text('john@email.com'),
                  trailing: const Icon(Icons.chevron_right),
                  onTap: () {},
                ),
                const Divider(height: 1),
                ListTile(
                  leading: const Icon(Icons.security),
                  title: const Text('Privacy'),
                  trailing: const Icon(Icons.chevron_right),
                  onTap: () {},
                ),
              ],
            ),
          ),

          const SizedBox(height: 24),

          // Preferences section
          const Text(
            'Preferences',
            style: TextStyle(
              fontSize: 14,
              fontWeight: FontWeight.bold,
              color: Colors.grey,
            ),
          ),
          const SizedBox(height: 8),
          Card(
            child: Column(
              children: [
                SwitchListTile(
                  secondary: const Icon(Icons.dark_mode),
                  title: const Text('Dark Mode'),
                  value: _darkMode,
                  onChanged: (value) {
                    setState(() => _darkMode = value);
                  },
                ),
                const Divider(height: 1),
                SwitchListTile(
                  secondary: const Icon(Icons.notifications),
                  title: const Text('Notifications'),
                  value: _notifications,
                  onChanged: (value) {
                    setState(() => _notifications = value);
                  },
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}
Tip: Group related settings inside a single Card with Dividers between items. Add section headers above each Card to organize settings by category. This is a pattern used in both iOS Settings and Android System Settings apps.

Practical Example: Product Card

E-Commerce Product Card

Card(
  clipBehavior: Clip.antiAlias,
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(16),
  ),
  elevation: 2,
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    mainAxisSize: MainAxisSize.min,
    children: [
      // Product image
      Stack(
        children: [
          Image.network(
            'https://picsum.photos/400/250',
            height: 180,
            width: double.infinity,
            fit: BoxFit.cover,
          ),
          Positioned(
            top: 8,
            right: 8,
            child: Container(
              padding: const EdgeInsets.symmetric(
                horizontal: 8,
                vertical: 4,
              ),
              decoration: BoxDecoration(
                color: Colors.red,
                borderRadius: BorderRadius.circular(8),
              ),
              child: const Text(
                '-20%',
                style: TextStyle(
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
          ),
        ],
      ),
      // Product info
      Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              'Wireless Headphones',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 4),
            const Text(
              'Premium sound quality with ANC',
              style: TextStyle(color: Colors.grey),
            ),
            const SizedBox(height: 12),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                const Text(
                  '\$79.99',
                  style: TextStyle(
                    fontSize: 20,
                    fontWeight: FontWeight.bold,
                    color: Colors.blue,
                  ),
                ),
                ElevatedButton.icon(
                  onPressed: () {},
                  icon: const Icon(Icons.add_shopping_cart),
                  label: const Text('Add'),
                ),
              ],
            ),
          ],
        ),
      ),
    ],
  ),
)

Practice Exercise

Build a complete screen with three sections: (1) A settings Card with at least four ListTile items, each with an icon, title, subtitle, and trailing widget (mix of switches, chevron icons, and badges). (2) A horizontal scrollable row of product Cards using ListView.builder with scrollDirection: Axis.horizontal. Each card should have an image, title, price, and add-to-cart button. (3) A contact Card with multiple ListTiles, dividers, and different leading widgets (icons and avatars). Challenge: Add a tap handler that shows a SnackBar with the selected item name.