Flutter Layouts & Responsive Design
Wrap & Flow
The Wrap Widget
The Wrap widget lays out its children in a horizontal or vertical run. When there is not enough space on the current run, it wraps to the next line. Unlike Row, which overflows when children exceed available space, Wrap gracefully handles overflow by creating new runs.
Key Difference: A Row places all children on one line and overflows if they don’t fit. A Wrap automatically moves overflowing children to a new line. Use Wrap whenever you have a dynamic number of children that may not fit in a single row.
Wrap vs Row: The Overflow Problem
Row Overflow vs Wrap
// ROW - causes overflow error when chips don't fit!
Row(
children: [
Chip(label: Text('Flutter')),
Chip(label: Text('Dart')),
Chip(label: Text('Firebase')),
Chip(label: Text('Material Design')),
Chip(label: Text('Responsive')),
Chip(label: Text('Animation')), // OVERFLOW!
],
)
// WRAP - gracefully wraps to next line
Wrap(
spacing: 8.0, // Horizontal gap between chips
runSpacing: 4.0, // Vertical gap between lines
children: [
Chip(label: Text('Flutter')),
Chip(label: Text('Dart')),
Chip(label: Text('Firebase')),
Chip(label: Text('Material Design')),
Chip(label: Text('Responsive')),
Chip(label: Text('Animation')), // Wraps to next line
],
)
Wrap Properties
Wrap provides several properties to control layout behavior:
Wrap Configuration
Wrap(
// Direction: horizontal (default) or vertical
direction: Axis.horizontal,
// Alignment of children within a run
alignment: WrapAlignment.start, // start, end, center, spaceBetween, spaceAround, spaceEvenly
// Spacing between children on the same run
spacing: 8.0,
// Spacing between runs (lines)
runSpacing: 12.0,
// Alignment of runs within the Wrap itself
runAlignment: WrapAlignment.start,
// Cross-axis alignment of children within a run
crossAxisAlignment: WrapCrossAlignment.center,
// Text direction (affects layout order for RTL)
textDirection: TextDirection.ltr,
children: [
// Children widgets...
],
)
Wrap Alignment Options
Alignment Examples
// Center-aligned tag cloud
Wrap(
alignment: WrapAlignment.center,
spacing: 8.0,
runSpacing: 8.0,
children: tags.map((tag) {
return Chip(
label: Text(tag),
backgroundColor: Colors.blue.shade50,
);
}).toList(),
)
// Space-evenly distributed buttons
Wrap(
alignment: WrapAlignment.spaceEvenly,
spacing: 8.0,
runSpacing: 12.0,
children: [
ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.share),
label: const Text('Share'),
),
ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.bookmark),
label: const Text('Save'),
),
ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.download),
label: const Text('Download'),
),
ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.print),
label: const Text('Print'),
),
],
)
// Vertical wrap (top to bottom, then next column)
Wrap(
direction: Axis.vertical,
spacing: 8.0,
runSpacing: 16.0,
children: items.map((item) => Text(item)).toList(),
)
Tip: Use
WrapAlignment.spaceBetween for tag clouds where you want items to spread across the full width. Use WrapAlignment.center when you want a balanced, visually centered layout.
Practical Example: Tag Cloud
Interactive Tag Cloud
class TagCloudWidget extends StatefulWidget {
final List<String> allTags;
const TagCloudWidget({super.key, required this.allTags});
@override
State<TagCloudWidget> createState() => _TagCloudWidgetState();
}
class _TagCloudWidgetState extends State<TagCloudWidget> {
final Set<String> _selectedTags = {};
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: widget.allTags.map((tag) {
final isSelected = _selectedTags.contains(tag);
return FilterChip(
label: Text(tag),
selected: isSelected,
onSelected: (selected) {
setState(() {
if (selected) {
_selectedTags.add(tag);
} else {
_selectedTags.remove(tag);
}
});
},
selectedColor: Colors.blue.shade100,
checkmarkColor: Colors.blue,
);
}).toList(),
);
}
}
Practical Example: Responsive Button Bar
Responsive Action Bar
class ResponsiveActionBar extends StatelessWidget {
const ResponsiveActionBar({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Wrap(
alignment: WrapAlignment.end,
spacing: 12.0,
runSpacing: 8.0,
children: [
OutlinedButton(
onPressed: () {},
child: const Text('Cancel'),
),
OutlinedButton(
onPressed: () {},
child: const Text('Save Draft'),
),
FilledButton(
onPressed: () {},
child: const Text('Publish'),
),
],
),
);
}
}
The Flow Widget
The Flow widget provides a highly efficient way to create custom layouts. It uses a FlowDelegate to position children with absolute precision. Flow is extremely performant because it uses a transformation matrix to position children without triggering a relayout, making it ideal for animations.
Warning: Flow is a low-level widget intended for custom animated layouts. For most wrapping scenarios, Wrap is simpler and sufficient. Only use Flow when you need custom positioning logic or animated layout transitions.
Flow with FlowDelegate
class SimpleFlowDelegate extends FlowDelegate {
final Animation<double> animation;
SimpleFlowDelegate({required this.animation}) : super(repaint: animation);
@override
void paintChildren(FlowPaintingContext context) {
final childCount = context.childCount;
for (int i = 0; i < childCount; i++) {
final dx = i * 60.0;
final dy = 0.0;
context.paintChild(
i,
transform: Matrix4.translationValues(dx, dy, 0),
);
}
}
@override
bool shouldRepaint(SimpleFlowDelegate oldDelegate) {
return animation != oldDelegate.animation;
}
@override
Size getSize(BoxConstraints constraints) {
return Size(constraints.maxWidth, 60.0);
}
}
// Usage
Flow(
delegate: SimpleFlowDelegate(animation: _animationController),
children: List.generate(5, (index) {
return Container(
width: 50.0,
height: 50.0,
decoration: BoxDecoration(
color: Colors.primaries[index % Colors.primaries.length],
shape: BoxShape.circle,
),
child: Center(
child: Text(
'\${index + 1}',
style: const TextStyle(color: Colors.white),
),
),
);
}),
)
Practical Example: Animated Circular Menu
Circular Flow Menu
class CircularMenuDelegate extends FlowDelegate {
final Animation<double> animation;
final double radius;
CircularMenuDelegate({
required this.animation,
this.radius = 100.0,
}) : super(repaint: animation);
@override
void paintChildren(FlowPaintingContext context) {
final childCount = context.childCount;
final angleStep = (pi * 0.5) / (childCount - 1);
for (int i = 0; i < childCount; i++) {
if (i == childCount - 1) {
// Last child is the main button (center)
context.paintChild(i);
} else {
// Fan out menu items in a quarter circle
final angle = i * angleStep;
final progress = animation.value;
final dx = -cos(angle) * radius * progress;
final dy = -sin(angle) * radius * progress;
context.paintChild(
i,
transform: Matrix4.translationValues(dx, dy, 0)
..scale(progress, progress),
opacity: progress,
);
}
}
}
@override
bool shouldRepaint(CircularMenuDelegate oldDelegate) {
return animation != oldDelegate.animation;
}
@override
Size getSize(BoxConstraints constraints) {
return const Size(56.0, 56.0);
}
}
// Usage in a StatefulWidget with AnimationController
class CircularMenu extends StatefulWidget {
const CircularMenu({super.key});
@override
State<CircularMenu> createState() => _CircularMenuState();
}
class _CircularMenuState extends State<CircularMenu>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
bool _isOpen = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
}
void _toggle() {
setState(() {
_isOpen = !_isOpen;
if (_isOpen) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Flow(
delegate: CircularMenuDelegate(
animation: _controller,
radius: 120.0,
),
children: [
_buildMenuItem(Icons.camera_alt, Colors.red),
_buildMenuItem(Icons.photo, Colors.green),
_buildMenuItem(Icons.videocam, Colors.blue),
// Main toggle button (last child)
FloatingActionButton(
onPressed: _toggle,
child: AnimatedIcon(
icon: AnimatedIcons.menu_close,
progress: _controller,
),
),
],
);
}
Widget _buildMenuItem(IconData icon, Color color) {
return FloatingActionButton(
mini: true,
backgroundColor: color,
onPressed: () {
_toggle();
// Handle menu item tap
},
child: Icon(icon),
);
}
}
Tip: The key advantage of Flow over other layout widgets is that
paintChildren uses a transformation matrix to position children. This means repositioning children does not trigger a layout pass — only a repaint — making animations extremely smooth and efficient.
Summary: Use
Wrap when you need children to automatically flow to the next line when space runs out — ideal for tag clouds, chip groups, and responsive button bars. Use Flow when you need complete control over child positioning with high-performance animations — ideal for custom menus and animated layout effects.