Widget Composition Patterns
Composing Widgets in Flutter
Flutter’s core philosophy is composition over inheritance. Instead of creating complex widgets through deep class hierarchies, you build sophisticated UIs by combining small, focused widgets together. This lesson covers the key patterns for composing widgets effectively: extracting widgets, builder patterns, callback patterns, and creating reusable components.
Extracting Widgets into Classes
The most fundamental composition pattern is extracting parts of your widget tree into separate widget classes. This keeps each class focused and easy to understand.
Before: Monolithic Build Method
Too Much in One Place
// BAD: Everything crammed into one build method
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: Column(
children: [
// 50+ lines for header section
Container(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const CircleAvatar(radius: 50),
const SizedBox(height: 12),
const Text('Ahmed', style: TextStyle(fontSize: 24)),
const Text('Flutter Developer'),
// ... more widgets
],
),
),
// 50+ lines for stats section
// 50+ lines for actions section
],
),
);
}
}
After: Extracted Widgets
Clean Composition
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: const Column(
children: [
ProfileHeader(
name: 'Ahmed',
title: 'Flutter Developer',
),
ProfileStats(
followers: 1200,
following: 340,
projects: 28,
),
ProfileActions(),
],
),
);
}
}
class ProfileHeader extends StatelessWidget {
final String name;
final String title;
const ProfileHeader({
super.key,
required this.name,
required this.title,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const CircleAvatar(radius: 50),
const SizedBox(height: 12),
Text(
name,
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
title,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
),
],
),
);
}
}
class ProfileStats extends StatelessWidget {
final int followers;
final int following;
final int projects;
const ProfileStats({
super.key,
required this.followers,
required this.following,
required this.projects,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_StatItem(label: 'Followers', count: followers),
_StatItem(label: 'Following', count: following),
_StatItem(label: 'Projects', count: projects),
],
);
}
}
class _StatItem extends StatelessWidget {
final String label;
final int count;
const _StatItem({required this.label, required this.count});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
count.toString(),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(label, style: const TextStyle(color: Colors.grey)),
],
);
}
}
Builder Pattern
Flutter uses the builder pattern extensively. A builder is a callback that receives context and returns a widget. This pattern enables lazy building, responsive layouts, and asynchronous data handling.
LayoutBuilder
LayoutBuilder provides the parent’s constraints so you can build responsive layouts.
Responsive Layout with LayoutBuilder
class ResponsiveGrid extends StatelessWidget {
const ResponsiveGrid({super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
if (constraints.maxWidth > 900) {
return _buildWideLayout();
} else if (constraints.maxWidth > 600) {
return _buildMediumLayout();
} else {
return _buildNarrowLayout();
}
},
);
}
Widget _buildWideLayout() {
return const Row(
children: [
Expanded(flex: 1, child: Placeholder()),
Expanded(flex: 2, child: Placeholder()),
Expanded(flex: 1, child: Placeholder()),
],
);
}
Widget _buildMediumLayout() {
return const Row(
children: [
Expanded(child: Placeholder()),
Expanded(child: Placeholder()),
],
);
}
Widget _buildNarrowLayout() {
return const Column(
children: [Placeholder(), Placeholder()],
);
}
}
FutureBuilder
FutureBuilder builds widgets based on the state of a Future. It handles loading, error, and data states automatically.
FutureBuilder Example
class UserProfile extends StatelessWidget {
const UserProfile({super.key});
Future<Map<String, String>> _fetchUser() async {
await Future.delayed(const Duration(seconds: 2));
return {'name': 'Ahmed', 'email': 'ahmed@example.com'};
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Map<String, String>>(
future: _fetchUser(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return Center(
child: Text('Error: \${snapshot.error}'),
);
}
final user = snapshot.data!;
return ListTile(
leading: const CircleAvatar(child: Icon(Icons.person)),
title: Text(user['name']!),
subtitle: Text(user['email']!),
);
},
);
}
}
future parameter in a StatelessWidget build method without caching the future. Each rebuild creates a new Future, causing infinite rebuilds. Either use a StatefulWidget and store the future in initState, or use a late final field.StreamBuilder
StreamBuilder works like FutureBuilder but for continuous streams of data.
StreamBuilder Example
class CounterStream extends StatelessWidget {
const CounterStream({super.key});
Stream<int> _countStream() async* {
for (int i = 1; i <= 10; i++) {
await Future.delayed(const Duration(seconds: 1));
yield i;
}
}
@override
Widget build(BuildContext context) {
return StreamBuilder<int>(
stream: _countStream(),
initialData: 0,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text('Error: \${snapshot.error}');
}
return Center(
child: Text(
'Count: \${snapshot.data}',
style: const TextStyle(fontSize: 48),
),
);
},
);
}
}
Callback Patterns
Callbacks like onPressed, onChanged, and onSubmitted are how child widgets communicate events back to their parents. Understanding and designing callback APIs is essential for reusable components.
Custom Widget with Callbacks
// A reusable rating widget that communicates via callbacks
class StarRating extends StatelessWidget {
final int rating;
final int maxRating;
final ValueChanged<int> onRatingChanged;
final Color activeColor;
final Color inactiveColor;
final double size;
const StarRating({
super.key,
required this.rating,
this.maxRating = 5,
required this.onRatingChanged,
this.activeColor = Colors.amber,
this.inactiveColor = Colors.grey,
this.size = 32,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(maxRating, (index) {
return GestureDetector(
onTap: () => onRatingChanged(index + 1),
child: Icon(
index < rating ? Icons.star : Icons.star_border,
color: index < rating ? activeColor : inactiveColor,
size: size,
),
);
}),
);
}
}
// Usage
StarRating(
rating: _currentRating,
onRatingChanged: (int newRating) {
setState(() => _currentRating = newRating);
},
)
Practical Example: Reusable Form Field
A custom form field widget that wraps styling, validation, and error display into a reusable component.
Reusable LabeledTextField
class LabeledTextField extends StatelessWidget {
final String label;
final String? hint;
final IconData? prefixIcon;
final bool obscureText;
final TextEditingController? controller;
final String? Function(String?)? validator;
final ValueChanged<String>? onChanged;
final TextInputType keyboardType;
const LabeledTextField({
super.key,
required this.label,
this.hint,
this.prefixIcon,
this.obscureText = false,
this.controller,
this.validator,
this.onChanged,
this.keyboardType = TextInputType.text,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
validator: validator,
onChanged: onChanged,
decoration: InputDecoration(
hintText: hint,
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
),
],
),
);
}
}
// Usage in a form
Form(
key: _formKey,
child: Column(
children: [
LabeledTextField(
label: 'Full Name',
hint: 'Enter your full name',
prefixIcon: Icons.person,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Name is required';
}
return null;
},
),
LabeledTextField(
label: 'Email',
hint: 'you@example.com',
prefixIcon: Icons.email,
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || !value.contains('@')) {
return 'Enter a valid email';
}
return null;
},
),
LabeledTextField(
label: 'Password',
hint: 'At least 8 characters',
prefixIcon: Icons.lock,
obscureText: true,
),
],
),
)
Practical Example: Custom Card Component
A reusable info card that accepts dynamic content through composition.
InfoCard Widget
class InfoCard extends StatelessWidget {
final String title;
final String? subtitle;
final IconData icon;
final Color iconColor;
final Widget? trailing;
final VoidCallback? onTap;
const InfoCard({
super.key,
required this.title,
this.subtitle,
required this.icon,
this.iconColor = Colors.blue,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: iconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: iconColor),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: Colors.grey),
),
],
],
),
),
if (trailing != null) trailing!,
],
),
),
),
);
}
}
// Usage
InfoCard(
icon: Icons.cloud_download,
iconColor: Colors.green,
title: 'Download Complete',
subtitle: 'report_2024.pdf (2.3 MB)',
trailing: const Icon(Icons.chevron_right),
onTap: () => debugPrint('Open file'),
)
Practical Example: Data-Driven Widget
Building a widget that renders dynamically based on a data model, combining builders and composition.
Data-Driven Settings List
class SettingItem {
final String title;
final String? subtitle;
final IconData icon;
final Color iconColor;
final Widget Function(BuildContext context) trailingBuilder;
const SettingItem({
required this.title,
this.subtitle,
required this.icon,
this.iconColor = Colors.blue,
required this.trailingBuilder,
});
}
class SettingsList extends StatelessWidget {
final String sectionTitle;
final List<SettingItem> items;
const SettingsList({
super.key,
required this.sectionTitle,
required this.items,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
sectionTitle.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey,
letterSpacing: 1.2,
),
),
),
Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
return Column(
children: [
ListTile(
leading: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: item.iconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(item.icon, color: item.iconColor, size: 20),
),
title: Text(item.title),
subtitle: item.subtitle != null
? Text(item.subtitle!)
: null,
trailing: item.trailingBuilder(context),
),
if (index < items.length - 1)
const Divider(height: 1, indent: 56),
],
);
}).toList(),
),
),
],
);
}
}
// Usage
SettingsList(
sectionTitle: 'Preferences',
items: [
SettingItem(
title: 'Dark Mode',
icon: Icons.dark_mode,
iconColor: Colors.indigo,
trailingBuilder: (ctx) => Switch(
value: _darkMode,
onChanged: (v) => setState(() => _darkMode = v),
),
),
SettingItem(
title: 'Language',
subtitle: 'English',
icon: Icons.language,
iconColor: Colors.teal,
trailingBuilder: (ctx) => const Icon(Icons.chevron_right),
),
SettingItem(
title: 'Font Size',
icon: Icons.text_fields,
iconColor: Colors.orange,
trailingBuilder: (ctx) => DropdownButton<String>(
value: _fontSize,
underline: const SizedBox(),
items: ['Small', 'Medium', 'Large']
.map((s) => DropdownMenuItem(value: s, child: Text(s)))
.toList(),
onChanged: (v) => setState(() => _fontSize = v!),
),
),
],
)
Summary
- Extract widgets -- Break monolithic build methods into small, focused widget classes
- LayoutBuilder -- Respond to parent constraints for responsive layouts
- FutureBuilder -- Build UI based on async Future results (loading, error, data)
- StreamBuilder -- Build UI reactively from continuous data streams
- Callback patterns -- Use
onPressed,onChanged,ValueChanged<T>to communicate events upward - Reusable components -- Accept parameters and callbacks to make widgets configurable and composable
- Data-driven widgets -- Use models and builder callbacks to render dynamic content
Practice Exercise
Create a reusable ProductCard widget that accepts a product model (name, price, imageUrl, rating, inStock). Include a StarRating sub-widget, an "Add to Cart" button with an onAddToCart callback, and a stock badge. Then build a product listing screen that uses FutureBuilder to load a list of products and displays them in a grid using LayoutBuilder to show 2 columns on phones and 3 columns on tablets.