Dart Extensions Tutorial: Improve your Flutter Code


Learn how to take your Flutter skills to the next level and make your code reusable with one of Dart’s most useful features: Dart extensions.

You might already have become familiar with basic Flutter and Dart knowledge. You might even have your first app already published. Yet there is always room for improvement. Dart Extensions can help make your code smoother and simplify some code usage.

This tutorial won’t teach you how to make a full Flutter app; the base is already done. Your job will be to refactor an already working CatFoodCalculator app with extensions. You’ll explore all their usages, including:

  • Basic extension creation.
  • More advanced usages, including extensions on enums, generics or nullable types.
  • Advice on when to use them and when not to.
Note: This Dart extensions tutorial assumes you know the basics of Dart and Flutter development. If you’re new to Flutter development, check out Getting Started With Flutter guide first.

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.

Unzip the downloaded file and open the starter project located in /starter within your favorite IDE. You can run the project on both mobile devices and web browsers.

The CatFoodCalculator app is already working. You’ll only refactor it to include extensions usages.

Look at the files in lib.

First, open lib/data/meal_data.dart. This is a data class holding your cat’s meal data.

Continue with lib/widgets/counter.dart. This is a UI widget used to increase a counter with buttons instead of having to type the new value.

Then, open lib/widgets/meal_info.dart. This widget is a form to type the recommended amount of food for a cat of a given weight. Note that it also holds the MealType enum.

Next, look at the widgets in lib/widgets/meal_repartition_result.dart. The MealRepartitionResult widget shows the resulting repartition based on MealData.

Finally, open lib/main.dart. This contains the core of your app.

In particular, look for MyHomePage and its state _MyHomePageState. The private method _mainColumnContent() returns the main parts of your UI. The methods _calculateRation() and _updateCatComment() contain the business rules.

Build and run the project. You should see a tool to calculate how much wet and dry food your cats need.

The initial state of the CatFoodCalculator app

Play with the values using the text fields or the + and buttons. See how the food repartition changes as a result.

What Is an Extension Method?

In this section, you’ll see what an extension method is and why it’s useful.

Purpose

Creating an extension on a class allows you to add methods to it without changing that class. Extensions are useful for adding features to classes you can’t or don’t want to change.

You might also use them to create shortcuts.

Comparison with Alternatives

When you can’t change a class but want to add a feature related to it, you have three options:

  1. Use a global or a static method
  2. Use a wrapper around the desired class and create the method in that wrapper
  3. Use an extension method

See each of them in the examples below:


// 1. Static method
class StaticMethods {
  static String addCat(String baseString){
    return '$baseString 🐱';
  }
}

// 2. Wrapper
class WrappedString {
  final String baseString;

  WrappedString(this.baseString);

  String addCat() {
    return '$baseString 🐱';
  }
}

// 3. Extension
extension Extension on String {
  String addCat(){
    return '$this 🐱';
  }
}

When starting with the same input String, all three methods add a ' 🐱' at the end of the input. The main difference is how you invoke them.


// 1. Static method
StaticMethods.addCat('bonjour'); // 'bonjour 🐱'

// 2. Wrapper
WrappedString('bonjour').addCat(); // 'bonjour 🐱'

// 3. Extension
'bonjour'.addCat(); // 'bonjour 🐱'

The extension method gives a more fluid API. It feels like it’s a classic method from the base class.

Creating and Using a Basic Extension

Now that you know what Dart extensions are, it’s time to learn more about their syntax. You’ll soon start adding them to the sample project.

Syntax

Look at the example below of a class and its extension.


class ClassToExtend {
  const ClassToExtend({
    required this.aNumber, 
    required this.aString,
    });
  
  final int aNumber;
  final String aString;
}

extension ExtensionName on ClassToExtend {
  String helloWorld() {
    return '$runtimeType says hello to the world';
  }

  String get hello => 'hello $aString';

  int operator +(int other) => aNumber + other;
}

An extension has a name and extends a specific class. In the example above, the name is ExtensionName and the extended class is ClassToExtend.

In the extension body, you can write new methods, getters and even operators! You can refer to public members of the extended class. In the example above, you access aString and aNumber. You can’t access private members of the extended class in the extension code.


final extendedClass = ClassToExtend(aNumber: 12, aString: 'there');

extendedClass.helloWorld(); // ClassToExtend says hello to the world
extendedClass.hello; // hello there
extendedClass + 8; // 20

You create an object of the extended class using a normal constructor. Then, you invoke the methods and the operators defined in the extension as if they were defined in the original class.

Creating StringCaseConverter Extension

For your first extension in the CatFoodCalculator app, you’ll add the firstLetterUppercase() method to String. Name that extension StringCaseConverter.

Start by creating the folder lib/utils. This folder will contain all the extensions you’ll create during this tutorial. Then, create the file string_case_converter.dart in it.

You’re now ready to create the extension StringCaseConverter. It should contain the firstLetterUppercase() method, which, when invoked on a String object, returns its capitalized version. If you’d like, try to do it yourself first. :]

Click the Reveal button to get this extension’s code.

[spoiler title=”Solution”]
Here’s the solution:

extension StringCaseConverter on String {
  String firstLetterUppercase() {
    final firstLetter = substring(0, 1);
    final rest = substring(1, length);
    return firstLetter.toUpperCase() + rest;
  }
}

[/spoiler]

With this, you can convert the first letter of a String to uppercase without touching the rest of the String.

Open lib/widgets/meal_info.dart and locate the _title() method. It returns a Text widget that displays “WET food” or “DRY food” based on the MealType. The line below transforms the name of the MealType enum to uppercase.


final foodType = widget.mealType.name.toUpperCase();

You’ll change this line to transform the name of the MealType enum to make only the first letter uppercase.

Start by importing StringCaseConverter:


import '../utils/string_case_converter.dart';

Now, replace the foodType assignment with the following:


final foodType = widget.mealType.name.firstLetterUppercase();

Only the first letter will be uppercase now.

Hot reload and see the updated title:

Updated Wet and Dry titles

Note the cat’s weight comment that appears once you set it to a value higher than 7.

Advanced Usages

Dart extensions can go way beyond simple String transformations. You can extend nullable types and generics and can even create private extensions.

Nullable Types

The cat’s weight comments don’t start with an uppercase. You’ll correct it using a slightly modified version of StringCaseConverter.

Look at the _catWeightCommentBuilder() method in lib/main.dart.

If you’d like to use firstLetterUppercase() on _catWeightComment, you’d have to deal with the fact that the _catWeightComment variable is nullable.

It could look like this:


_catWeightComment?.firstLetterUppercase()

Note the ? to handle nullable values.

But there’s an even easier approach: You can make extensions on nullable types.

Replace StringCaseConverter in lib/utils/string_case_converter.dart with this code:


extension StringCaseConverter on String? {
  String firstLetterUppercase() {
    if (this == null || this!.isEmpty) {
      return '';
    } else {
      final firstLetter = this!.substring(0, 1);
      final rest = this!.substring(1, this!.length);
      return firstLetter.toUpperCase() + rest;
    }
  }
}

Because you handle the nullable values in firstLetterUppercase(), you don’t need the ? on your method calls anymore.

Go back to lib/main.dart and change _catWeightCommentBuilder() to use the updated extension:


Widget _catWeightCommentBuilder() {
  return Text(
    _catWeightComment.firstLetterUppercase(),
    textAlign: TextAlign.center,
    style: Theme.of(context).textTheme.bodyMedium?.copyWith(
          fontStyle: FontStyle.italic,
        ),
  );
}

Don’t forget to import the extension.


import '../utils/string_case_converter.dart';

_catWeightComment will now start with an uppercase.

Hot reload to see that small change.

The comment text is updated by using Dart extensions on a nullable type

Generics

Like regular classes and methods, you can create Dart extensions on generic types. You’ll make one to insert an element between each original list element.

Add a separator between each element of a list

In the picture above, the original list contains numbers you would like to separate by a comma. This is what you want to achieve with your extension.

To do this on a generic List, make an extension on List<T>, where “T” is the type of the elements in the list.

First, create a file named separated_list.dart in lib/utils/, then paste the following code in it:

extension SeparatedList<T> on List<T> {
  List<T> separated(T separator) {
    final newList = <T>[];
    for (var i = 0; i < length; i++) {
      if (i == 0) {
        newList.add(this[i]);
      } else {
        newList.add(separator);
        newList.add(this[i]);
      }
    }
    return newList;
  }
}

The separated() method adds a separator between each element of the original List. Note that both the List and the new element should be of type T.

Here’s an example of how to use it:


final myExampleList = <String>['Sam', 'John', 'Maya'];
print(myExampleList.separated(', ').join()); // Prints "Sam, John, Maya"

The ListView widget has a separated constructor like this.

You can now achieve something resembling it with Column and Row.

In lib/main.dart, locate the _mainColumnContent() method. It returns the children of the main Column of your widget tree. Note the space variable at the method’s beginning.


const space = SizedBox(height: 20);

It’s used to add space among all the children of the Column widget, which is the app’s main structure. Delete that variable and all the lines where it appears.

Now, you need to use the new extension. Locate the comment TODO Add separation between items with an extension and replace the entire line with the code below.


].separated(const SizedBox(height: 20));

With this code, you invoke separated() on the widget list before returning it. The extension method inserts the SizedBox between each original items.

Again, don’t forget to import the extension.


import '../utils/separated_list.dart';

You can also make an extension method directly on List<Widget> rather than on a generic List. Paste the following code at the end of lib/utils/separated_list.dart:


extension SpacedWidgets on List<Widget> {
  // 1. 
  // double defaultHorizontalSpace = 8;

  // 2.
  static const double _defaultHorizontalSpace = 8;
  static const double _defaultVerticalSpace = 8;

  // 3.
  List<Widget> _spaced(
      {required double horizontalSpace, required double verticalSpace}) {
    // 4.
    return separated(SizedBox(width: horizontalSpace, height: verticalSpace));
  }

  List<Widget> horizontallySpaced({
    double horizontalSpace = _defaultHorizontalSpace,
  }) {
    return _spaced(horizontalSpace: horizontalSpace, verticalSpace: 0);
  }

  List<Widget> verticallySpaced({
    double verticalSpace = _defaultVerticalSpace,
  }) {
    return _spaced(horizontalSpace: 0, verticalSpace: verticalSpace);
  }
}

In the code above, you create an extension on a list of widgets. The extension defines a couple of methods that add space among the widgets in the list.

Some important limitations and features of Dart extensions are highlighted in the code:

  1. Declaring instance fields is not allowed.
  2. Implementing static fields is allowed.
  3. You can create private methods inside an extension.
  4. It’s possible to reuse other extensions in an extension, like SeparatedList is used in SpacedWidgets.

Remember to import the missing references.


import 'package:flutter/widgets.dart';

Thanks to SpacedWidgets, you can now go back to lib/main.dart and replace your previous separated() call with the new extension.


// Replace
].separated(const SizedBox(height: 20));

// with
].verticallySpaced(verticalSpace: 20);

You’re now using SpacedWidgets instead of SeparatedList.

Private Dart Extensions

Like classes, you can make extensions private by starting their name with an _.

To make SpacedWidgets private, move it from lib/utils/separated_list.dart to main.dart because you’ll use it only there, and rename it to _SpacedWidgets:

extension _SpacedWidgets on List<Widget>{
  // ...
}

Because it starts with an underscore, it’s now private; you can only use it in the main.dart file.

You can also make extensions private by omitting their name:

extension on List<Widget>{
  // ...
}

However, naming an extension make it easier to understand what it does. Moreover, it gives you an easier way to manage conflicts, as you’ll see later.

Although it might sound good to make private extensions, you should identify where you can reuse them in your code and change them to be public. Extensions are helpful because they make code highly reusable.

Static Functions, Constructors and Factories

Dart extensions aren’t yet perfect. They can’t:

  • Create new constructors
  • Create factories

You can declare static functions like in the following example:


extension StringPrinter on String {
  // 1.
  // static String print() {
  //   print(this);
  // }

  // 2.
  static String helloWorld() {
    return 'Hello world';
  }
}

Here’s a breakdown of the code snippet above:

  1. You can’t use this in a static method. That’s because it’s static: You make the call on the class, not on an instance of the class.
  2. You can define a regular static method.
    But its usage might disappoint you:

// Doesn't work
// String.helloWorld();

// Doesn't work
// 'something'.helloWorld();

// Works!
StringPrinter.helloWorld();

You can’t use String to call helloWorld(). You have to use StringPrinter directly, which isn’t ideal. Being able to call String.helloWorld() was the initial intention, after all.

For the CatFoodCalculator app, you might have liked to return a Slider with a theme included in its constructor instead of having to wrap the Slider with a SliderTheme.

Copy the following code and paste it in a new file lib/utils/themed_slider.dart:


import 'package:flutter/material.dart';

extension ThemedSlider on Slider {
  static Widget withTheme({
    Key? key,
    required double value,
    required Function(double) onChanged,
    Function(double)? onChangeStart,
    Function(double)? onChangeEnd,
    double min = 0.0,
    double max = 1.0,
    int? divisions,
    String? label,
    Color? activeColor,
    Color? inactiveColor,
    Color? thumbColor,
    MouseCursor? mouseCursor,
    String Function(double)? semanticFormatterCallback,
    FocusNode? focusNode,
    bool autofocus = false,
    required SliderThemeData themeData,
  }) {
    return SliderTheme(
      data: themeData,
      child: Slider(
        key: key,
        value: value,
        onChanged: onChanged,
        onChangeStart: onChangeStart,
        onChangeEnd: onChangeEnd,
        min: min,
        max: max,
        divisions: divisions,
        label: label,
        activeColor: activeColor,
        inactiveColor: inactiveColor,
        thumbColor: thumbColor,
        mouseCursor: mouseCursor,
        semanticFormatterCallback: semanticFormatterCallback,
        focusNode: focusNode,
        autofocus: autofocus,
      ),
    );
  }
}

The extension wraps the Slider with a SliderTheme instead of having to deal with it directly.

Now, in lib/main.dart, import the new file with:


import '../utils/themed_slider.dart';

Then, locate SliderTheme, right below the // TODO Replace SliderTheme with ThemedSlider comment. Replace SliderTheme, the child of the Expanded widget, with a call to the new extension as in the code below:


child: ThemedSlider.withTheme(
  value: _mealRepartition,
  min: 0,
  max: _nbMeals.toDouble(),
  divisions: _nbMeals,
  onChanged: (newVal) {
    setState(() {
      _mealRepartition = newVal;
    });
  },
  themeData: const SliderThemeData(
    trackHeight: 16,
    tickMarkShape: RoundSliderTickMarkShape(tickMarkRadius: 6),
    thumbShape: RoundSliderThumbShape(enabledThumbRadius: 16),
    thumbColor: Color(0xffffa938),
  ),

You have to call ThemedSlider.withTheme() instead of Slider.withTheme(). This limitation is actively discussed in a GitHub issue.

Dart Extensions on Enums

Besides classes, you can also create extensions on enum.

Open lib/widgets/meal_info.dart and note the MealType enum declaration at the top of the file.

The amount of food you should feed to your cat depends on the specific food, and the package usually shows the recommended daily intake. One might not know where to find the correct information to type in this form. That’s why there’s a Help button, which displays a popup:

The popups giving more information on how much food a cat should eat

The popup content changes based on the MealType. In your next extension, you’ll create a method to show this popup.

Add an extension MealTypeDialog in a new file, lib/utils/meal_type_dialog.dart:


import 'package:flutter/material.dart';

import '../widgets/meal_info.dart';

extension MealTypeDialog on MealType {
  Future<void> infoPopup(BuildContext context) {
    final text = this == MealType.wet
        ? 'You can find this data printed on the pack of wet food'
        : 'Your bag of dry food should have this data printed on it';
    return showDialog<void>(
        context: context,
        builder: (context) {
          return AlertDialog(
            content: Text(text),
            actions: [
              ElevatedButton(
                onPressed: () {
                  Navigator.of(context).pop();
                },
                child: const Text('OK'),
              )
            ],
          );
        });
  }
}

This extension displays the same dialog you get when you use the onInfoPressed() method from _MealInfoState. It shows a different text based on the MealType.

In meal_info.dart, import the file with the new extension:


import '../utils/meal_type_dialog.dart';

Then, look for the // TODO Replace onInfoPressed with an extension comment and replace the onPressed with a call to the MealTypeDialog extension.


onPressed: () => widget.mealType.infoPopup(context),

The infoPopup() method now takes care of displaying the dialog. You don’t need onInfoPressed() anymore, so you can delete it.

And voilà! Thanks to your extension, you’re now displaying a popup directly by calling a method on an enum.

Handling Conflicts

The CatFoodCalculator app is quite simple: There’s no API call nor local storage. If you’d like to implement it, converting your objects to JSON is a good starting point. One way of doing it is to use jsonEncode().

Create an extension JsonConverter in a new file, lib/utils/json_converter.dart:


import 'dart:convert';

extension JsonConverter on dynamic {
// ...
}

You’ll need dart:convert because you’ll use jsonEncode(). Note that the extension is dynamic: It’s available to all types, including your target class MealData.

Now, add a new method to this extension:


String stringify() {
  return jsonEncode(this);
}

As you can see, jsonEncode() does the entire job.

In main.dart, find the // TODO add a save button here comment and replace it with a Save button as in the code below.

List<Widget> _mainColumnContent() {
  return [
    ...
    ElevatedButton(
      onPressed: _saveMealData,
      child: const Text('SAVE'),
    ),
  ].verticallySpaced(verticalSpace: 20);
}

You’ll use this button to simulate saving MealData in _saveMealData(). Create a new method in the _MyHomePageState widget:


void _saveMealData() {
  final mealData = MealData.dry(
    nbMeals: _mealRepartition.round(),
    eachAmount: _calculateRation(MealType.dry),
  );

  print('Json : ${mealData.stringify()}');
}

Import JsonConverter extension:


import 'utils/json_converter.dart';

Instead of saving MealData somewhere, you’ll only print it to the console in this example, thanks to print(). This is what you should read in the console:


{
   "nbMeals": 3,
   "mealType": "dry",
   "eachAmount": 122
}

An alternative stringify method could include the type of the object as the initial key:

{
   "MealData":{
      "nbMeals": 3,
      "mealType": "dry",
      "eachAmount": 122
   }
}

Go back to json_converter.dart and create another extension:


extension JsonConverterAlt on dynamic {
  String stringify() {
    return '{$runtimeType: ${jsonEncode(this)}}';
  }
}

This one includes the runtimeType as the first key.

Both JsonConverter and JsonConverterAlt have a method named stringify(). In a real app, this might happen due to using an external library.

Go back to main.dart and note the error on stringify():

Note: A member named ‘stringify’ is defined in extension ‘JsonConverter’ and extension ‘JsonConverterAlt’, and none is more specific.

One way to solve it is to use the hide feature in the import:


import 'utils/json_converter.dart' hide JsonConverterAlt;

The error disappears, but you can’t use both extensions on main.dart with this method.

Another way to solve this problem is to use the names of your extensions: That’s why you should name them. Remove the hide JsonConverterAlt code you added to the import statement and replace the body of the _saveMealData() method with the following:


final mealData = MealData.dry(
  nbMeals: _mealRepartition.round(),
  eachAmount: _calculateRation(MealType.dry),
);

print('Json v1 : ${JsonConverter(mealData).stringify()}');
print('Json v2 : ${JsonConverterAlt(mealData).stringify()}');

Wrapping your class with the extension helps to resolve conflicts when they occur simply, even if the API is a bit less fluid now.

Common Extension Usages

Now that you’ve learned what Dart extensions are and how to create them, it’s time to see some common usages in real apps.

Adding Features to Classes

Extensions let you add features to existing Flutter and Dart classes without re-implementing them.

Here are a few examples:

  • Convert a Color to a hex String and vice versa.
  • Separating the children of a ListView using the same Widget as a separator in the entire app.
  • Convert a number of milliseconds from an int to a more humanly readable String.

You can also add features to classes from external packages available at pub.dev.

People often put the code to add these features in Utils classes such as StringUtils. You might already have seen that in some projects, even in other languages.

Extensions provide a good alternative to them with a more fluid API. If you choose this approach, your StringUtils code will become an extension instead of a class. Here are a few methods you could add to a StringUtils extension:

  • String firstLetterUppercase()
  • bool isMail()
  • bool isLink()
  • bool isMultiline(int lineLength)
  • int occurrences(String pattern)

When writing a static method, consider whether an extension would work first. An extension might give you the same output but with a better API. That’s especially nice when that method is useful in several places in your code. :]

Dart Extensions as Shortcuts

In Flutter, many widgets require the current BuildContext, such as the Theme and Navigator. To use a TextStyle defined in your Theme within the build() method of your widgets, you’ll have to write something like this:


Theme.of(context).textTheme.headlineSmall

That’s not short, and you might use it several times in your app. You can create extensions to make that kind of code shorter. Here are a few examples:


import 'package:flutter/material.dart';

extension ThemeShortcuts on BuildContext {
  // 1.
  TextTheme get textTheme => Theme.of(this).textTheme;

  // 2.
  TextStyle? get headlineSmall => textTheme.headlineSmall;

  // 3.
  Color? get primaryColor => Theme.of(this).primaryColor;
}

Here’s a breakdown of the code above:

  1. You make the textTheme more easily accessible:

// Without extension
Theme.of(context).textTheme
// With extension
context.textTheme
  1. Use your previous textTheme method to return a TextStyle. The code is clearly shorter:

// Without extension
Theme.of(context).textTheme.headlineSmall
// With extension
context.headlineSmall
  1. You can add as many methods as you’d like to make shortcuts, such as to get the primaryColor:

// Without extension
Theme.of(this).primaryColor
// With extension
context.primaryColor

Popular Packages Using Extensions

You might already use popular packages that let you use extensions.

Routing packages often use them to navigate directly from BuildContext. In auto_route for instance, you can go to the previous page with context.popRoute(). The same goes with go_router, where you can use context.pop().

Translation packages provide methods on String via extensions to translate them to the correct language. With easy_localization, you can call tr() on your String to translate it: hello.tr(). You can even call it on Text: Text('hello').tr().

State management packages like Provider also use them. For instance, you can watch a value from a Provider with context.watch()

You can even search for extensions on pub.dev, and you’ll find packages that only contain extensions to add common features to native types or to be used as shortcuts.

Extensions Everywhere … Or not?

Dart extensions give superpowers to your classes. But with great power comes great responsibility.

Writing shorter code isn’t always the best way to make a project grow, especially when you’re part of a team. When working on a Flutter project, Flutter and Dart APIs are the common base every developer should know.

  1. If you rely too much on extensions, you can lose familiarity with the general Flutter and Dart APIs.

You might have difficulties when joining new projects where extensions aren’t used. It might take you longer to get familiar with the project.

  1. Other developers are not familiar with your extensions.

If other developers join your project, they might have difficulty understanding your code and following your practices. They’ll have to learn your extensions in addition to everything else they’ll need to learn, like the business and the architecture.

In general, use extensions but don’t rely too much on them.

Where to Go From Here?

Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial. Now, you should better understand Dart extensions and how to use them in your Flutter apps.

A package that makes heavy use of extensions is RxDart.. Learn more about it in RxDart Tutorial for Flutter: Getting Started.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the discussion below!

Source link

Share

Leave a Reply

Your email address will not be published. Required fields are marked *