Card & ListTile
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 toRoundedRectangleBorderwith a 12px radius in Material 3.margin-- Empty space around the card.clipBehavior-- How the card clips its content. UseClip.antiAliasfor 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),
),
],
),
),
],
),
)
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 aTextwidget.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 whenselectedis 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: () {},
)
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);
},
),
],
),
),
],
),
);
}
}
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.