Several Months ago, I started to learn a little bit of Dart and Flutter to investigate the possibilities of both, language and framework, when creating cross-platform applications, although mainly focused on mobiles. The idea of these articles, which I imagine will be much shorter than usual, is none other than to put here things that I have been finding over time and that I have not seen quite clear in the official documentation, which by the way is very good. As I tend to forget a lot of things easyly, I think this could be very useful to my future self.
Today I have been struggling to fix an issue that is perhaps very simple, but had been giving me problems for a day. It turns out that, for some code I had been doing as part of a private app, I had code that displayed dialogs (info and confirmation dialogs in fact). Those dialogs were displayed by a couple of functions. The problem was that in Flutter you need to pass a parameter called Build Context so that the constructor (the showDialog function) can generate the dialog and display it on screen.
Yesterday, after running the language’s linter, I got a message indicating, basically, that I should avoid passing a Build Context from an asynchronous function.
This made things difficult, basically because in short, my app needs to make API calls, for which Flutter recommends using asynchronous functions. But after making the API call, I also needed to display the result of that call, for which Flutter recommends that if I plan to use a dialog, I should not use asynchronous functions. So the thing was that I need to use asynchronous functions so that the API can be called (which has responses that can take a couple of seconds, so if I don’t call it asynchronously Flutter penalizes that with app response time), but I also need to give the Build Context to the function that creates the dialogs, and passing the Build Context would not even be advisable from an asynchronous function.
What is the build context, Anyway?
Flutter works thanks to several abstraction layers. In general terms, it is composed of 3 important layers that relate to each other to form the state of an app.
- Widgets: High level controls are called Widgets. Normally a widget can be seen as a button, a simple text label, even an application is defined as a specific type of widget. It is very easy for flutter to create Widgets, that’s why when there is an update (for example, a counter increases or new information is available in a text), Flutter redraws the whole Widget (but only the updated widget) or rebuilds its position in the widget tree. Widgets are cheap to create and are immutable, that is why they are rebuilt when updating their state. Some widgets may have a mutable part, which can change (e.g., store variables inside the mutable state of a widget), but that is another matter. When a Widget is created, Flutter automatically creates a Render object and an element and places them respectively in the Widgets tree, in the elements tree and in the render objects tree. A curious fact about widgets is that they never know their position or properties regarding to other widgets.
- Render Object: This object is created as part of any widget and is not normally interacted with directly. It represents all the placement part and performs the calculations so that the widget is correctly placed on the screen in the corresponding positions. It is mutable and is usually updated (by associating it with a new widget, if its associated widget is rebuilt) or can be removed. Unlike Widgets, render objects are very expensive to create and Flutter will usually try to recycle them as much as possible.
- element object: The elements are, basically, the “glue” that allows to join a widget with its render object. Normally Flutter encapsulates the elements in an object that is used for many other things, called Build Context. When creating a new widget, Flutter assigns, within its build function (mandatory for every widget), a new Build Context. The Build Context contains the configuration of the widget (the element object) and allows to place it with respect to other widgets and to give a screen position. It is also used to access the navigator object, very useful when using functions that send to other screens in an application, and can also be used to find previous widgets in the tree, for example.
Code example
This is the code of a very basic Flutter application that displays a dialog inside an async function:
import 'package:flutter/material.dart';
void main() => runApp(
MaterialApp(
home: HomePage(),
),
);
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: SafeArea(
child: Center(
child: Text('test')
)
),
floatingActionButton: FloatingActionButton(
child: Text("Click here"),
onPressed: () => showMyDialog(context),
),
);
}
}
void showMyDialog(BuildContext context) async {
showDialog(
context: context,
builder: (context) => Center(
child: Material(
color: Colors.transparent,
child: Text('Hello'),
),
)
);
}
This app’s workflow is really simple. The Home Widget, which is called as part of a MaterialApp, shows a button that when pressed will display a dialog. This dialog looks just fine, but it is called inside an asynchronous function. In this example, the function does not perform anything that requires it to be asynchronous, but in the real world, it could be loading an API, processing information on a device, running a long task, etc.
This has started to appear as an issue in the dart Linter and although it appears as experimental right now, it is recommended to avoid passing the Build Context to asynchronous functions as this is apparently the cause of many crashes and is somewhat difficult to diagnose. What they recommend is not necessarily the solution I decided to use, but I preferred to define a key for the navigator object.
Using navigatorKey
The navigator class allows us to navigate through the different screens of the current app. This set of screens is called stack, since it can be imagined as if one screen is stacked on another, and a newer one is placed just above the others, and so on. Hence, when we move backwards, for example in Android, the Stack removes one screen after another until we reach the beginning of our app. The navigator has ways to include new screens at the beginning of the Stack (which causes them to be displayed), remove the last screen added (which causes a dialog or window to close), or get some information from the widget that is placed in the foreground, such as its Build Context. This is important since we need to pass the Calling widget’s Build context to our dialog, since if it is not done this way , it probably will not be shown correctly on screen or might have unexpected issues at runtime.
Now, in order to access these properties of the navigator object, it is necessary to register it globally. For this kind of things, Flutter uses the keys. Every widget, when it is created, receives a key. If the widget will not be reused or we are not interested in accessing the widget itself, validating its content or doing anything with it, we do not need to have any idea of the key that each widget has. But sometimes we need to keep the possibility of accessing this widget from other parts of the application. For this specific example, we will define a special type of key, called navigatorKey, that will allow us, after being defined and passed as a parameter to our MaterialApp, to access the navigator from wherever we are. To carry out this process, we must do the following:
- Create another library in the package where the Flutter app lives, for example a file named navigator_key.dart. Don’t forget that the library should be located inside the lib directory of any application package.
- Add the following content to the file:
import 'package:flutter/material.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
- In the file where you define the MaterialApp object, import the new library by doing something like “import ‘package:myapp/navigator_key.dart’;”, if your dart package were named ‘myapp’.
- Pass the already defined NavigatorKey as an argument to the MaterialApp’s constructor:
void main() => runApp(
MaterialApp(
home: HomePage(),
navigatorKey: navigatorKey,
),
);
- Finally, every time you need to access to the current Widget’s Build context, make sure you have imported the navigator_key.dart library and you will be able to use the navigatorKey.currentContext object.
Correct code example
Finally, here is a version of the correct main.dart file, assuming that the navigator_key.dart library has already been saved and that the package is called test.
import 'package:flutter/material.dart';
import 'package:test/navigator_key.dart';
void main() => runApp(
MaterialApp(
home: HomePage(),
navigatorKey: navigatorKey,
),
);
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: SafeArea(
child: Center(
child: Text('test')
)
),
floatingActionButton: FloatingActionButton(
child: Text("Click here"),
onPressed: () => showMyDialog(),
),
);
}
}
void showMyDialog() async {
showDialog(
context: navigatorKey.currentContext!,
builder: (context) => Center(
child: Material(
color: Colors.transparent,
child: Text('Hello'),
),
)
);
}
This code can already be used in asynchronous functions without problems, without the need to resort to other types of libraries or third-party packages to make the latest Build Context available globally.