Widget Composition Patterns in Flutter: A Comprehensive Guide
•13 min read
Widget composition is a fundamental concept in Flutter development. This guide explores various patterns and techniques for creating reusable, maintainable, and efficient widget compositions.
1. Basic Composition Patterns
Container Pattern
class CustomCard extends StatelessWidget { final Widget child; final Color backgroundColor; final double elevation; final BorderRadius? borderRadius; final EdgeInsets padding; const CustomCard({ Key? key, required this.child, this.backgroundColor = Colors.white, this.elevation = 2.0, this.borderRadius, this.padding = const EdgeInsets.all(16), }) : super(key: key); @override Widget build(BuildContext context) { return Material( elevation: elevation, borderRadius: borderRadius ?? BorderRadius.circular(8), child: Container( padding: padding, decoration: BoxDecoration( color: backgroundColor, borderRadius: borderRadius ?? BorderRadius.circular(8), ), child: child, ), ); } } // Usage CustomCard( elevation: 4.0, padding: EdgeInsets.all(24), child: Column( children: [ Text('Title'), Text('Description'), ], ), )
Wrapper Pattern
class CustomWrapper extends StatelessWidget { final Widget child; final EdgeInsets padding; final Color backgroundColor; final List<Widget> Function(Widget child)? wrapWith; const CustomWrapper({ Key? key, required this.child, this.padding = const EdgeInsets.all(16), this.backgroundColor = Colors.white, this.wrapWith, }) : super(key: key); @override Widget build(BuildContext context) { Widget result = Container( padding: padding, color: backgroundColor, child: child, ); if (wrapWith != null) { final widgets = wrapWith!(result); for (final widget in widgets.reversed) { result = widget; } } return result; } } // Usage CustomWrapper( wrapWith: (child) => [ Center(child: child), Padding( padding: EdgeInsets.all(8), child: child, ), ], child: Text('Hello'), )
2. Advanced Composition Patterns
Builder Pattern with State
class InteractiveBuilder extends StatelessWidget { final Widget Function( BuildContext context, bool isHovered, bool isPressed, ) builder; const InteractiveBuilder({ Key? key, required this.builder, }) : super(key: key); @override Widget build(BuildContext context) { return StatefulBuilder( builder: (context, setState) { bool isHovered = false; bool isPressed = false; return MouseRegion( onEnter: (_) => setState(() => isHovered = true), onExit: (_) => setState(() => isHovered = false), child: GestureDetector( onTapDown: (_) => setState(() => isPressed = true), onTapUp: (_) => setState(() => isPressed = false), onTapCancel: () => setState(() => isPressed = false), child: builder(context, isHovered, isPressed), ), ); }, ); } } // Usage InteractiveBuilder( builder: (context, isHovered, isPressed) { return Container( color: isPressed ? Colors.blue.shade900 : isHovered ? Colors.blue.shade700 : Colors.blue, child: Text('Interactive Button'), ); }, )
Decorator Pattern with Animations
class AnimatedDecorator extends StatefulWidget { final Widget child; final Duration duration; final Curve curve; final BoxDecoration Function(bool isAnimating) decorationBuilder; const AnimatedDecorator({ Key? key, required this.child, required this.decorationBuilder, this.duration = const Duration(milliseconds: 300), this.curve = Curves.easeInOut, }) : super(key: key); @override _AnimatedDecoratorState createState() => _AnimatedDecoratorState(); } class _AnimatedDecoratorState extends State<AnimatedDecorator> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation<double> _animation; @override void initState() { super.initState(); _controller = AnimationController( duration: widget.duration, vsync: this, ); _animation = CurvedAnimation( parent: _controller, curve: widget.curve, ); } @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => _controller.forward(), onExit: (_) => _controller.reverse(), child: AnimatedBuilder( animation: _animation, builder: (context, child) { return Container( decoration: widget.decorationBuilder(_animation.value > 0), child: child, ); }, child: widget.child, ), ); } @override void dispose() { _controller.dispose(); super.dispose(); } } // Usage AnimatedDecorator( decorationBuilder: (isAnimating) => BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(isAnimating ? 16 : 8), boxShadow: [ BoxShadow( color: Colors.black12, blurRadius: isAnimating ? 8 : 4, offset: Offset(0, isAnimating ? 4 : 2), ), ], ), child: Padding( padding: EdgeInsets.all(16), child: Text('Hover me!'), ), )
3. Layout Composition Patterns
Responsive Layout with Breakpoints
class ResponsiveLayout extends StatelessWidget { final Widget mobile; final Widget? tablet; final Widget? desktop; final double tabletBreakpoint; final double desktopBreakpoint; const ResponsiveLayout({ Key? key, required this.mobile, this.tablet, this.desktop, this.tabletBreakpoint = 600, this.desktopBreakpoint = 1200, }) : super(key: key); @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth >= desktopBreakpoint) { return desktop ?? tablet ?? mobile; } else if (constraints.maxWidth >= tabletBreakpoint) { return tablet ?? mobile; } else { return mobile; } }, ); } } // Usage ResponsiveLayout( mobile: SingleChildScrollView( child: Column(children: items), ), tablet: GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, ), itemBuilder: (_, index) => items[index], ), desktop: GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, ), itemBuilder: (_, index) => items[index], ), )
Adaptive Grid Layout
class AdaptiveGrid extends StatelessWidget { final List<Widget> children; final double minCrossAxisExtent; final double spacing; final EdgeInsets padding; const AdaptiveGrid({ Key? key, required this.children, this.minCrossAxisExtent = 300, this.spacing = 16, this.padding = const EdgeInsets.all(16), }) : super(key: key); @override Widget build(BuildContext context) { return GridView.builder( padding: padding, gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: minCrossAxisExtent, crossAxisSpacing: spacing, mainAxisSpacing: spacing, childAspectRatio: 3 / 2, ), itemCount: children.length, itemBuilder: (context, index) => children[index], ); } } // Usage AdaptiveGrid( minCrossAxisExtent: 250, spacing: 24, children: List.generate( 20, (index) => Card( child: Center(child: Text('Item $index')), ), ), )
4. State Management Patterns
Inherited Widget with Generic Type
class InheritedData<T> extends InheritedWidget { final T data; final void Function(T value)? onDataChanged; const InheritedData({ Key? key, required this.data, this.onDataChanged, required Widget child, }) : super(key: key, child: child); static InheritedData<T> of<T>(BuildContext context) { return context.dependOnInheritedWidgetOfExactType<InheritedData<T>>()!; } @override bool updateShouldNotify(InheritedData<T> oldWidget) { return data != oldWidget.data; } } // Usage class UserData { final String name; final String email; const UserData({required this.name, required this.email}); } InheritedData<UserData>( data: UserData(name: 'John', email: 'john@example.com'), child: Builder( builder: (context) { final userData = InheritedData.of<UserData>(context).data; return Text('${userData.name} (${userData.email})'); }, ), )
Composable State Widget
class ComposableState<T> extends StatefulWidget { final T initialValue; final Widget Function(BuildContext context, T value, ValueSetter<T> onChanged) builder; const ComposableState({ Key? key, required this.initialValue, required this.builder, }) : super(key: key); @override _ComposableStateState<T> createState() => _ComposableStateState<T>(); } class _ComposableStateState<T> extends State<ComposableState<T>> { late T value; @override void initState() { super.initState(); value = widget.initialValue; } void setValue(T newValue) { setState(() { value = newValue; }); } @override Widget build(BuildContext context) { return widget.builder(context, value, setValue); } } // Usage ComposableState<int>( initialValue: 0, builder: (context, value, onChanged) { return Column( children: [ Text('Count: $value'), ElevatedButton( onPressed: () => onChanged(value + 1), child: Text('Increment'), ), ], ); }, )
5. Composition with Lifecycle Management
Disposable Widget Pattern
abstract class DisposableWidget extends StatefulWidget { const DisposableWidget({Key? key}) : super(key: key); @override DisposableWidgetState createState(); } abstract class DisposableWidgetState<T extends DisposableWidget> extends State<T> { final List<StreamSubscription> _subscriptions = []; final List<ChangeNotifier> _notifiers = []; void addSubscription(StreamSubscription subscription) { _subscriptions.add(subscription); } void addNotifier(ChangeNotifier notifier) { _notifiers.add(notifier); } @override void dispose() { for (final subscription in _subscriptions) { subscription.cancel(); } for (final notifier in _notifiers) { notifier.dispose(); } super.dispose(); } } // Usage class MyWidget extends DisposableWidget { @override _MyWidgetState createState() => _MyWidgetState(); } class _MyWidgetState extends DisposableWidgetState<MyWidget> { late StreamController<String> _controller; late ValueNotifier<int> _counter; @override void initState() { super.initState(); _controller = StreamController<String>(); _counter = ValueNotifier<int>(0); addSubscription(_controller.stream.listen(print)); addNotifier(_counter); } @override Widget build(BuildContext context) { return Container(); } }
6. Best Practices
1. Widget Composition Guidelines
- Keep widgets focused and single-purpose
- Use composition over inheritance
- Make widgets reusable and configurable
- Document widget parameters and usage
2. Performance Optimization
// Use const constructors const MyWidget( key: Key('unique'), child: Text('Hello'), ) // Implement shouldRebuild for custom widgets class CustomWidget extends StatelessWidget { final String data; const CustomWidget({Key? key, required this.data}) : super(key: key); @override Widget build(BuildContext context) { return CustomRenderObject( data: data, shouldRebuild: (oldWidget) => data != oldWidget.data, ); } }
3. Error Handling
class ErrorBoundary extends StatelessWidget { final Widget child; final Widget Function(Object error)? errorBuilder; const ErrorBoundary({ Key? key, required this.child, this.errorBuilder, }) : super(key: key); @override Widget build(BuildContext context) { return ErrorWidget.builder = (details) { if (errorBuilder != null) { return errorBuilder!(details.exception); } return Container( padding: EdgeInsets.all(16), child: Text('An error occurred: ${details.exception}'), ); }; } }
4. Testing Considerations
// Make widgets testable class TestableWidget extends StatelessWidget { final VoidCallback? onTap; final String text; const TestableWidget({ Key? key, required this.text, this.onTap, }) : super(key: key); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Text(text, key: Key('text_$text')), ); } } // Test example testWidgets('TestableWidget works correctly', (tester) async { bool tapped = false; await tester.pumpWidget( TestableWidget( text: 'Test', onTap: () => tapped = true, ), ); expect(find.byKey(Key('text_Test')), findsOneWidget); await tester.tap(find.byType(TestableWidget)); expect(tapped, isTrue); });
Conclusion
Effective widget composition is essential for building maintainable Flutter applications. Remember to:
- Use appropriate composition patterns
- Keep widgets focused and reusable
- Implement proper state management
- Consider performance implications
- Make widgets testable
- Handle errors gracefully
- Document widget usage
By following these patterns and best practices, you can create Flutter applications that are:
- More maintainable
- More reusable
- More testable
- More scalable
- More performant