Back to Posts

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:

  1. Use appropriate composition patterns
  2. Keep widgets focused and reusable
  3. Implement proper state management
  4. Consider performance implications
  5. Make widgets testable
  6. Handle errors gracefully
  7. 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