Adapted from some notes I took in https://github.com/contrasting/Clutter.
- Why is inheritance not a good idea?
- Containment pattern
- Specialization pattern
- Builder pattern
- Bonus: hooks
TLDR: pass children/child, pass props, pass a builder.
If you came from an OOP paradigm, the first thing you might reach out for is to inherit from another widget to reuse its code. It's certainly what I did! Let's look at why that's not a good idea.
Let's say you have a very simple widget, MyText.
class MyText extends StatelessWidget {
const MyText({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Text('My Text');
}
}Now you want to customise the appearance of MyText, to give it a blue background. So you inherit from it:
class BlueText extends MyText {
const BlueText({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
color: Colors.blue,
padding: const EdgeInsets.all(16.0),
child: super.build(context),
);
}
}Ok, this works, and it seems like you've managed to reuse the MyText code in the base class BlueText.
But now consider this:
- What if I want some widget, other than
MyText, to have a blue background? - What if I want to make
BlueTextstateful?
Inheritance creates a very tight coupling between the parent and child classes. And further, stateful and stateless widgets have incompatible interfaces.
A much more flexible way is to use composition instead of inheritance.
The first pattern is called containment (borrowed from React terminology). In this pattern, we move the shared code into a parent widget, which asks for a child to render.
So we move the shared code to BlueBackground, which takes a child widget as a parameter:
class BlueBackground extends StatelessWidget {
final Widget child;
const BlueBackground({Key? key, required this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
color: Colors.blue,
padding: const EdgeInsets.all(16.0),
child: child,
);
}
}Now, we can build the BlueText widget by composing BlueBackground and MyText:
class BlueText extends StatelessWidget {
const BlueText({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlueBackground(
child: MyText(),
);
}
}In other words, instead of saying that BlueText is a MyText, we say that BlueText consists of a BlueBackground which has a MyText. Now, MyText can be replaced with any other widget to give it a blue background!
The child parameter can be replaced with children if you need to contain multiple widgets instead of just one. And indeed, you can name the parameter in other ways, like icon, leading or trailing.
Sometimes you don't need to pass a child to render. For example, suppose you don't ever, ever want to use a BlueBackground for anything other than MyText.
But let's say you want to be able to change the color of the background.
In this case, we can use the specialization pattern (again, terminology borrowed from React). In this pattern, we move the shared code into a widget that asks for parameters to customise its behaviour.
Going back to our example, we move the shared code to ColoredText, which takes a color parameter:
class ColoredText extends StatelessWidget {
final Color color;
const ColoredText({Key? key, required this.color}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
color: color,
padding: const EdgeInsets.all(16.0),
child: MyText(),
);
}
}So now we can build BlueText by composing ColoredText - instead of passing a child to render like the containment pattern, we pass properties to configure the color:
class BlueText extends StatelessWidget {
const BlueText({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ColoredText(color: Colors.blue);
}
}In other words, BlueText consists of ColoredText with the color blue. And we can reuse ColoredText for many different colors.
Of course, you can combine containment and specialization to great effect, for example with a ColoredBackground.
So far, we've only talked about reusing stateless widgets - but what if we need to reuse stateful logic across widgets?
For example, we have a RandomColoredText where, every time you tap the text, its color (not background) changes:
class RandomColoredText extends StatefulWidget {
const RandomColoredText({Key? key}) : super(key: key);
@override
State<RandomColoredText> createState() => _RandomColoredTextState();
}
class _RandomColoredTextState extends State<RandomColoredText> {
Color _color = Colors.blue;
@override
Widget build(BuildContext context) {
return GestureDetector(
child: Text(
'My Text',
style: TextStyle(color: _color),
),
onTap: () {
setState(() {
_color = Colors.primaries[Random().nextInt(Colors.primaries.length)];
});
},
);
}
}Now we would like not only to have a random text color, but to reuse that random color logic for any widget.
The first instinct might be to use containment and pass a child to render - but think about it, how would you configure the color of the child inside the parent? Remember that Flutter is declarative, so you can't do widget.setColor().
The answer is that instead of asking for a child, you ask for a builder that allows you to pass state down to the child. This is equivalent to render props in React.
So in RandomColored, we pass the color parameter when calling the builder method:
class RandomColored extends StatefulWidget {
final Widget Function(Color color) builder;
const RandomColored({Key? key, required this.builder}) : super(key: key);
@override
State<RandomColored> createState() => _RandomColoredState();
}
class _RandomColoredState extends State<RandomColored> {
Color _color = Colors.blue;
@override
Widget build(BuildContext context) {
return GestureDetector(
child: widget.builder(_color),
onTap: () {
setState(() {
_color = Colors.primaries[Random().nextInt(Colors.primaries.length)];
});
},
);
}
}And once again, we can compose RandomColoredText with RandomColored and a Text widget:
class RandomColoredText extends StatelessWidget {
const RandomColoredText({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return RandomColored(
builder: (color) => Text(
'My Text',
style: TextStyle(color: color),
),
);
}
}And RandomColored can be reused for any other widget, not just Text. Neat.
If you've used the provider package, you'll notice that the Consumer widget asks for a builder. Is it the same pattern?
Let's move the stateful logic out of the StatefulWidget into a ChangeNotifier:
class RandomColorNotifier extends ChangeNotifier {
Color _color = Colors.blue;
Color get color => _color;
void changeColor() {
_color = Colors.primaries[Random().nextInt(Colors.primaries.length)];
notifyListeners();
}
}Now we can compose RandomColoredText with a Consumer that consumes RandomColorNotifier:
class RandomColoredText extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<RandomColorNotifier>(
builder: (context, notifier, child) {
return GestureDetector(
child: Text(
'My Text',
style: TextStyle(color: notifier.color),
),
onTap: () {
notifier.changeColor();
},
);
}
);
}
}Notice that:
- We managed to encapsulate the stateful logic inside the notifier, so that we can reuse it to build other widgets (e.g. Container), not just Text.
- However, we could not reuse code for the
GestureDetector. Naturally, this is becauseChangeNotifieris not a widget. - Like in the
StatefulWidget, thebuilderallows passing state down to the child. However with theConsumer, they are all consuming the sameChangeNotifier(assuming the same provider above in the widget tree), so all widgets would share the same color.
If you come from a React background, you might be asking... what about hooks?
Sorry, hooks aren't a thing in Flutter. Oh wait... what was that? Sorry, I meant that hooks aren't a thing in vanilla Flutter ;)
So let's rewrite the random color logic above in terms of hooks. First let's rewrite RandomColoredText:
class RandomColoredText extends HookWidget {
@override
Widget build(BuildContext context) {
final color = useState<Color>(Colors.blue);
return GestureDetector(
child: Text(
'My Text',
style: TextStyle(color: color.value),
),
onTap: () {
color.value = Colors.primaries[Random().nextInt(Colors.primaries.length)];
},
);
}
}But we still haven't encapsulated the random color logic here. For that, we need a custom hook:
https://reactjs.org/docs/composition-vs-inheritance.html
https://reactjs.org/docs/render-props.html
https://reactjs.org/docs/hooks-intro.html