Level: beginner
This tutorial is a complete introduction to Dart extensions, a language feature that was added in Dart 2.6.
As part of this, we will see:
- How to enable extensions inside an existing Flutter project
- What extensions are, and where they can be used to improve our code
- Where they should not be used
As they say, with great power comes great responsibility. And Dart extensions are one language feature that should be used sensibly.
Alright, so let's get started, and see how we can enable extensions in our Flutter projects.
How to enable extensions
Open the pubspec.yaml
file & update the environment section to use the SDK version 2.6.0 or greater:
environment:
sdk: ">=2.6.0 <3.0.0"
That's it. We can now use extensions in our Flutter projects.
Getting started with extensions
You can use Dartpad to experiment with extensions.
Suppose that we're writing a program to convert temperature values from Celsius to Farhenheit.
We could use a double
to represent a temperature value.
First off, we can define an extension on double
, along some methods to convert degrees from celsius to Farhenheit, and viceversa:
extension on double {
// note: `this` refers to the current value
double celsiusToFarhenheit() => this * 1.8 + 32;
double farhenheitToCelsius() => (this - 32) / 1.8;
}
Once we have this, we can define a double
value, representing a temperature of 10.0 degrees Celsius, and convert it to Farhenheit:
double tempCelsius = 10.0;
double tempFarhenheit = tempCelsius.celsiusToFarhenheit();
print('${tempCelsius}C = ${tempFarhenheit}F');
If we run this code, it will print:
10C = 50F
Takeaway: we can use extensions to add new functionality to existing types.
Alongside methods, we can also add getters and setters inside our extensions.
But we cannot add instance variables.
In other words, we can use extensions to extend the functionality of our types, but not the underlying data.
By the way, at the beginning of this tutorial I said that we should be mindful when we use extensions, and there are cases where it's best to not use them.
And this temperature conversion example, is one such case.
In fact, nothing stops us from writing code like this:
tempCelsius.celsiusToFarhenheit().celsiusToFarhenheit();
This converts the temperature to Farhenheit more than once, which doesn't make sense.
Takeaway: when designing an API to work with temperatures, extensions are not a good fit, and we should define a completely different type instead.
Here's a much better approach, which creates a new Temperature
type:
class Temperature {
final double celsius;
double get farhenheit => celsius * 1.8 + 32;
}
This always represents the temperature in degrees Celsius internally.
And we can get the Farenheit value with the provided getter.
Then, we can add some factory constructors to make our API nicer:
class Temperature {
Temperature._({this.celsius});
factory Temperature.celsius(double degrees) => Temperature._(celsius: degrees);
factory Temperature.farhenheit(double degrees) => Temperature._(celsius: (degrees - 32) / 1.8);
final double celsius;
double get farhenheit => celsius * 1.8 + 32;
}
And in our main file, we can create temperature values like this:
final tempA = Temperature.celsius(10);
final tempB = Temperature.farhenheit(50);
In this example, using a bespoke type for Temperature:
- leverages the type system
- makes our code much easier to undestand
- avoids overloading generic numeric types such as
double
, with domain specific logic for temperature conversion.
Let's get back on track and learn more about extensions.
Generics
Dart extensions can be used with generic types.
So our next goal is to write an extension that would allow us to write code like this:
final total1 = [1, 2, 3].sum(); // 6
final total2 = [1.5, 2.5, 3.5].sum(); // 7.5
To do this, we can create a named extension on Iterable<T>
, called IterableNumX
:
extension IterableNumX<T extends num> on Iterable<T> {}
This extension operates on any type T
that extends num
(num
is a type that represents a number in Dart). So this extension will work both with collections of type int
and double
.
And then, we can define a sum()
method with a return type of T
, and implement it like so:
extension IterableNumX<T extends num> on Iterable<T> {
T sum() {
// 1. initialize sum
var sum = (T == int ? 0 : 0.0) as T;
// 2. calculate sum
for (var current in this) {
if (current != null) { // only add non-null values
sum += current;
}
}
return sum;
}
}
This method initializes sum
to either interger 0
or double 0
depending on the type of T
.
Then it calculates the sum by iterating through the values of this
(our iterable object), and returns the result at the end.
With this extension, we can express the sum of all elements in a numeric collection like this:
final total1 = [1, 2, 3].sum(); // 6
final total2 = [1.5, 2.5, 3.5].sum(); // 7.5
Takeaway: Extensions can be named, and they can work with generics.
Let's see in which other ways we could use them.
Padding with less boilerplate
We can use extensions to extend existing Flutter types, and reduce boilerplate code for common layout tasks.
Consider this code:
class ColumnLayout extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('One Line'),
Text('Another line'),
customRaisedButton(
context,
onPressed: () {},
child: Text('OK'),
borderRadius: 8,
),
],
),
);
}
}
This represents a Column
layout with two Text
widgets and a custom button.
Used like this, it stretches the content edge-to-edge within the parent container.
So it is desirable to add some Padding to each widget inside this column:
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: Text('One Line'),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text('Another line'),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: customRaisedButton(
context,
onPressed: () {},
child: Text('OK'),
borderRadius: 8,
),
),
],
)
This works but the Padding
widgets add a lot of noise and make our code less readable.
So to overcome that, we could define an extension on the Widget
class:
extension WidgetPaddingX on Widget {
Widget paddingAll(double padding) => Padding(
padding: EdgeInsets.all(padding),
child: this,
);
}
This takes the current widget (this
), add a Padding
as a parent and return it.
The benefit of this is that we can rewrite the code above as:
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('One Line').paddingAll(8),
Text('Another line').paddingAll(8),
customRaisedButton(
context,
onPressed: () {},
child: Text('OK'),
borderRadius: 8,
).paddingAll(8),
],
)
The result is the same, but the code is much more readable.
Credit: I borrowed this specific example from this blog post by Quick Bird Studios.
Note on named extensions
Using the example above, we could remove the extension name, and import it in a separate file:
// widget_padding_x.dart
import 'package:flutter/material.dart';
extension on Widget {
Widget paddingAll(double padding) => Padding(
padding: EdgeInsets.all(padding),
child: this,
);
}
// text_with_padding.dart
import 'package:flutter/material.dart';
import 'widget_padding_x.dart';
class TextWithPadding extends StatelessWidget {
@override
Widget build(BuildContext context) {
// this doesn't compile
return Text('test').paddingAll(8);
}
}
However, this code will not compile, because the extension does not have a name and the paddingAll()
method cannot be resolved by the compiler.
Takeaway: Always name extensions if you want to import and use them from different files.
Extensions with static methods: extending ShapeBorder
There is one Flutter API that I always forget how to use correctly, and that is the ShapeBorder
class that we can use to define various shapes.
Here's an example of a custom button class that uses a RoundedRectangeBorder
to define the shape that we want.
import 'package:flutter/material.dart';
class CustomRaisedButton extends StatelessWidget {
CustomRaisedButton({
Key key,
this.child,
this.color,
this.borderRadius: 2.0,
this.onPressed,
}): super(key: key);
final Widget child;
final Color color;
final double borderRadius;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return RaisedButton(
child: child,
color: color,
disabledColor: color,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(borderRadius),
),
),
onPressed: onPressed,
);
}
}
We can extract the code to created the RoundedRectangleBorder
into an extension on ShapeBorder
:
extension ShapeBorderX on ShapeBorder {
static ShapeBorder roundedRectangle(double radius) {
return RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(radius),
),
);
}
}
With this, every time we need a rounded rectangle shape, we can just write:
shape: ShapeBorderX.roundedRectangle(borderRadius),
This is easier to remember.
Creating widgets with static extension methods
In my code, I often need to create a widget class that has a parent Bloc
or ChangeNotifier
.
Here's some code using a ChangeNotifierProvider
along with a ValueNotifier
, as a way to implement the Flutter counter example, without using StatefulWidget
with setState()
.
class CounterPage extends StatelessWidget {
static Widget create(BuildContext context) {
return ChangeNotifierProvider<ValueNotifier<int>>(
builder: (_) => ValueNotifier<int>(0),
child: CounterPage(),
);
}
// build method here
}
Here we use a static create()
method, which can be invoked as CounterPage.create(context)
at the calling site.
However, this is an additive helper method, and the CounterPage
class is already complete without it.
If we want better separation of concerns, we can move this into an extension called CounterPageX
:
extension CounterPageX on CounterPage {
static Widget create(BuildContext context) {
return ChangeNotifierProvider<ValueNotifier<int>>(
builder: (_) => ValueNotifier<int>(0),
child: CounterPage(),
);
}
}
And then we could update the call site to use CounterPageX.create(context)
.
In general, I feel that extensions may be a good place to hold static helper methods for existing classes.
Because they let us more clearly separate all the main functionality inside our classes, from any class-specific helpers that we may need.
Word of caution
Now that we have seen all these examples, you may think that exceptions are the next big thing, and want to use them everywhere.
Not so fast.
Just because we can use extensions, it doesn't mean that we should use them everywhere.
Ultimately, we want our code to be understandable and easy to navigate.
If we overuse extensions, they can quickly become a dumping ground for helper methods that we don't know where else to put.
So I encourage you to only use them where it makes sense.
A good criteria when designing extension-based APIs based is to ask yourself if your changes will lead to a better API.
And a desirable trait of a good API is that it should be hard to use incorrectly.
Our first temperature conversion example illustrates this quite well:
// not recommended
extension on double {
double celsiusToFarhenheit() => this * 1.8 + 32;
double farhenheitToCelsius() => (this - 32) / 1.8;
}
// much better
class Temperature {
Temperature._({this.celsius});
factory Temperature.celsius(double degrees) => Temperature._(celsius: degrees);
factory Temperature.farhenheit(double degrees) => Temperature._(celsius: (degrees - 32) / 1.8);
final double celsius;
double get farhenheit => celsius * 1.8 + 32;
}
void main() {
double tempCelsius = 10.0;
// API **can** used incorrectly
tempCelsius.celsiusToFarhenheit().celsiusToFarhenheit();
final tempA = Temperature.celsius(10);
// API **can't** be used incorrectly
tempA.farhenheit;
}
What extensions can't do
The things that extensions can't do include:
- Adding constructors to extensions (whether they are factory constuctors or not)
- Extending interfaces or methods from base classes (with @override)
If you try the latter, you'll get an error like this:
Extensions can't declare members with the same name as a member declared by 'Object'.
DartX
DartX
is a package which adds a lot of useful extension methods to types in the Dart Language.
And this includes additional functionality to work with Iterable
, strings, time utilities and much more.
So I encourage you to get familiar with this package, and include it in your own projects.
styled_widget
styled_widget
is another package that started as an experiment, showing how widget styling APIs could be improved, using a syntax that is similar to the SwiftUI framework by Apple.
The README we shows how to style Text
and Icon
widgets, in a way that is more readable than the "standard" Flutter APIs. Example:
@override
Widget build(BuildContext context) => FlutterLogo()
.padding(all: 20)
.backgroundColor(Colors.blue)
.constraints(width: 100, height: 100)
.borderRadius(all: 10)
.elevation(10)
.alignment(Alignment.center);
Under the hood, this uses extensions together with copyWith
, as a way to modify an existing widget, and replace it with a new copy that has some different properties.
Note: this package is still an experiment which is subject to breaking changes, and is not ready for production-use. This may or may not change in the future.
In any case, it would be interesting to see if the Flutter team will lead the way and improve some widget APIs to use extensions in the future.
Conclusion
Thanks for reading!
If you find some cool new ways of using extensions in your own projects, let me know on Twitter.