Make Your Macros: Step-by-Step Guide for Flutter Macros
Table of contents
Have you experienced boilerplate issues in developing Flutter apps?
I do. I always write static route()
methods for an argument for Navigator.of(context).push()
method.
class NextPage extends StatelessWidget {
static Route route() {
return MaterialPageRoute(builder: (context) => const NextPage());
}
}
// usage
Navigator.of(context).push(NextPage.route());
This route()
method is actually useful. On the other hand, however, I wouldn't say I like to implement the method for all the page widgets. That's the issue of boilerplate.
Now, Dart's new feature "macros" would solve the problem.
In this article, I will introduce how we can write macro classes step-by-step considering making one to solve the boilerplate of route()
method above.
Step-by-step guide
Before heading to the first step, please note that this article will not cover how we can configure our projects to use the macros feature.
As the feature is still in beta, and our setup will potentially change, we can check the latest information in the Flutter doc below.
https://dart.dev/language/macros
Also, make sure the snippet in this article can be invalid when the feature comes to stable. This article is written based on the Flutter version Channel master, 3.24.0-1.0.pre.28
.
Now, are you ready to code your macro? Let's get started with "step 0".
Step 0: Decide what your macro codes
The first and most important step is to think about how your macros work and what issues they solve.
Because macros, in the end, just write boilerplate codes instead of us, we must consider what code they should write automatically.
In this article, our macro's business is generating the code below.
static Route route() {
return MaterialPageRoute(builder: (context) => const NextPage());
}
The usage looks like below.
@RouteMacro
class NextPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return // NextPage UI
}
}
What we have to do is just adding @RouteMacro
above the class definition, and that generates route()
method automatically. Don't you think that's awesome?
Step 1: Define a macro class
Fixing the brief image of what our macro does, our first step is to define a macro class.
Make route_macro.dart
inside lib/
folder as we usually do to make dart files.
Then, define RouteMacro
class with macro
keyword.
macro class RouteMacro {
}
All the macro classes need a const
constructor.
macro class RouteMacro {
const RouteMacro();
}
You can also accept arguments as much as you need in your logic, as long as the arguments are also made with const
.
Step 2: Implement relevant macro interface
For macro classes, dart provides a lot of Macro
interfaces, such as LibraryTypesMacro
, ClassDefinitionMacro
, EnumDeclarationsMacro
, etc.
You can choose relevant interface(s) and implement it(them) in your macro classes overriding a method defined in the interface.
macro class RouteMacro implements ClassDeclarationsMacro {
@override
Future<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) {
}
}
You can choose "relevant" interfaces based on "where" and "what" you want the macro to generate.
Let's say, for instance, if you want to add "declarations" to "classes", the relevant interface would be ClassDeclarationsMacro
. If you want to add "types" to "libraries", you may want LibraryTypesMacro
.
For accurate understanding, macros has an idea of "phases", and the interfaces are designed based on that idea. See the page below for more information.
https://github.com/dart-lang/language/blob/main/working/macros/feature-specification.md#phases
You can check all the Macro
interfaces in macros.dart
below. They must inspire what you want to do with Dart macros!
https://github.com/dart-lang/sdk/blob/main/pkg/_macros/lib/src/api/macros.dart
Because we want to add a declaration of route()
method to NextPage
class, our Macros interface would be ClassDeclarationMacro
. See the code above again for how our RouteMacro
class looks like now.
Step 3: Confirm the implementing method and its arguments
As each Macro
interface has a single abstract method to be implemented, we can override it and implement how our macros behave there.
@override
Future<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) {
// implement our macro's operation here
}
We have two arguments passed, ClassDeclaration
and MemberDeclarationBuilder
.
ClassDeclaration
preserves information about the class where we attach our macro, NextPage
in this case. Definitions of the class, such as its name, its implementing interfaces, or other modifiers can be detected from clazz
object.
@override
Future<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) {
clazz.identifier.name; // -> "NextPage"
clazz.hasSealed; // -> false
}
MemberDeclarationBuilder
has two main functionalities, detecting declarations of the clazz
and generating codes.
@override
Future<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) async {
await builder.fieldsOf(clazz); // -> List<FieldDeclaration>
builder.declareInLibrary(
DeclarationCode.fromString('final foo = 1'),
);
}
.fieldsOf(clazz)
will find all the fields declared in the given clazz
. As the method returns Future
, note that we put await
before calling it and async
at the method declaration.
That functionality helps us write operations using declarations of fields, methods, or some other elements in the class.
.declareInLibrary()
and .declareInType()
are the very methods we need for generating our code.
Both receive DeclarationCode
that represents generated code, and we have two options for how we describe the code, DeclarationCode.fromString
and DeclarationCode.fromParts
. We will discuss how to use them later.
Depending on what interface we implement, the names of the overridden methods and their arguments are slightly different, but the concept doesn't change.
Step 4: Generate our code!
As we have seen before, our code to be generated here is below.
static Route route() {
return MaterialPageRoute(builder: (context) => const NextPage());
}
And we have DeclarationCode.fromString
for generating code with a simple String
. So our macro can be implemented with the code below.
@override
Future<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) async {
builder.declareInType(
DeclarationCode.fromString(
'''
static Route route() {
return MaterialPageRoute(builder: (context) => const NextPage());
}
''',
),
);
}
It doesn't work, however, because we have two main problems here.
the compiler can't understand where
MaterialPageRoute
andRoute
came fromNextPage
can change depending on what class the macro is attached to
So, let's tackle the issues one by one.
Import material.dart
What we have to do when the compiler can't find the definition of types is import
.
As MaterialPageRoute
is defined in material.dart
, we can add import 'package:flutter/material.dart';
at the top of the generated file.
Here, we have two options for generating our code, .declareInLibrary()
and .declareInType()
. What we choose in this case is .declareInLibrary()
since import
can't be declared in the type but in the library, or the file in other words.
Thus, add the snippet below.
builder.declareInLibrary(DeclarationCode.fromString(
"import 'package:flutter/material.dart';",
));
This enables the compiler to find MaterialPageRoute
and Route
.
Dynamically change the Widget's name
One macro can be used multiple times at multiple places, so it's irrelevant to hardcode NextPage
in RouteMacro
.
Here comes the second option, DeclarationCode.fromParts
. Take a look at the code below.
final widget = clazz.identifier;
builder.declareInType(DeclarationCode.fromParts([
'''
static Route route() {
return MaterialPageRoute(
builder: (context) => const ''',
widget,
'''(),
);
}
''',
]),
While DeclarationCode.fromString
just add a code with a single String
, DeclarationCode.fromParts
constructs a code with the List
of String
and Identifier
.
String
can be used for the static snippet, and Identifier
for the dynamically changing type.
Instead of just embed String
that represents the name of the widget like ${clazz.identifier.name}
, passing Identifier
to the List enables our macro to maintain import
for the classes.
We can check how the generated code looks like by opening the augmentation.
import 'package:macros_practice/next_page.dart' as prefix0;
...
return MaterialPageRoute(
builder: (context) => const prefix0.NextPage(),
);
Without declaring import
by hand, our macro implicitly adds the line with a prefix to avoid a conflict when we have different classes with the same name.
Step 5: Use the generated code!
That's it! Our RouteMacro
is now implemented below.
import 'package:macros/macros.dart';
macro class RouteMacro implements ClassDeclarationsMacro {
const RouteMacro();
@override
Future<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) async {
builder.declareInLibrary(DeclarationCode.fromString(
"import 'package:flutter/material.dart';",
));
final widget = clazz.identifier;
builder.declareInType(DeclarationCode.fromParts([
'''
static Route route() {
return MaterialPageRoute(
builder: (context) => const ''',
widget,
'''(),
);
}
''',
]),
);
}
}
We don't need to declare route()
method at any page widget. What we have to do is just write @RouteMacro
above the type declaration.
@RouteMacro()
class NextPage extends StatelessWidget {
const NextPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold();
}
}
This enables us to call NextPage.route()
wherever we need.
onTap: () {
Navigator.of(context).push(NextPage.route());
}
Again, don't forget to refer to the Flutter doc for how to run the app with this experimental feature.
https://dart.dev/language/macros
Wrap up
So far, we have discussed how we make our own macro classes, and how each API can be used.
We have to be aware that the feature is still experimental and the APIs introduced in this article can be changed when the stable version is released, so it would be important to focus not on concrete usage but on the core concept of it.
Though we have only few resources to learn how to make macros, unfortunately, I hope this article will help your first step jumping into creating your macros!
Learn more
If you are now interested in creating macros, check the official samples, and it will inspire you more!
https://github.com/dart-lang/language/tree/main/working/macros/example