Prototype UI
Generating UI Constructs
All UI constructs are generated using the 'generate ui' subcommand. For example:
$ dart bin/xxx_afib.dart generate ui QuickStartScreen
...
$ dart bin/xxx_afib.dart generate ui MainDrawer
...
$ dart bin/xxx_afib.dart generate ui EditItemDialog
...
$ dart bin/xxx_afib.dart generate ui SelectDateBottomSheet
...
$ dart bin/xxx_afib.dart genreate ui TodoItemWidget --member-variables "bool showDetails;" --resolve-variables "TodoItem"
...
--member-variables
and --resolve-variables
flags, which add variables to the UI element's route parameter.
As with other generate commands, the final suffix of the construct's name determines what kind of UI element it is, and the form of the command correlates to the path of the resulting file:
Yields
Screens vs. Widgets
The next few sections refer to 'screens', but are equally true for drawer, dialogs, and bottom sheets. Widgets are special because they are created by your code, not AFib itself. They are described in more detail in subsequent sections. I recommend you master using the top-level screen constructs before you begin using AFib-aware widgets.
Note that you can use traditional stateless Flutter widgets (many of the examples below do) and stateful Flutter widgets (with their state stored in your route parameter, explained below). Your widgets do not need to be AFib-aware, though as you use AFib more you will have cases where AFib-aware widgets are useful.
Efficiently Generating UI
All of the generate ui
variants above take two optional parameters, --member-variables
and --resolve-variables
. These parameters work just as they do in the generate state
command, creating member and resolve variables in the route parameter for the generated UI element.
For example, the command:
dart bin/tdle_afib.dart generate ui TodoDetailsScreen --member-variables "bool showDetails; String filter;" --resolve-variables "TodoItem todoItem;"
class TodoDetailsScreenRouteParam extends AFScreenRouteParam {
// it generates these member variables
final bool showDetails;
final String filter;
final String todoItemId;
TodoDetailsScreenRouteParam({
// it generates these constructor parameters.
required this.showDetails,
required this.filter,
required this.todoItemId,
});
// it generates this factory method, used by navigate push.
factory TodoDetailsScreenRouteParam.create({
required bool showDetails,
required String filter,
required String todoItemId,
}) {
return (
TodoDetailsScrenRouteParam(
showDetails: showDetails,
filter: filter,
todoItemId: todoItemId,
);
)
}
// it generates these revise methods
TodoDetailsScreenRouteParam reviseShowDetails(bool value) => copyWith(showDetails: value);
TodoDetailsScreenRouteParam reviseFilter(String value) => copyWith(filter: value);
// it generates these resolve methods
TodoItem? resolveTodoItem(TodoItemsRoot todoItems) => todoItems.findById(todoItemId);
// It generates this copywith method
TodoDetailsScreenRouteParam copyWith({
bool? showDetails,
String? filter,
String? todoItemId,
}) {
return TodoDetailsScreenRouteParam(
showDetails: showDetails,
filter: filter,
todoItemId: todoItemId,
);
}
}
class TodoDetailsScreenSPI extends ... {
...
// it generates these accessor methods on the SPI
bool get showDetails => context.p.showDetails;
String get filter => context.p.filter;
TodoItem? get todoItem => context.p.resolveTodoItem(context.s.todoItems);
// it generates these event handlers on the SPI
void onChangedShowDetails(bool value) {
final revised = context.p.reviseShowDetails(value);
context.updateRouteParam(value);
}
void onChangedFilter(String filter) {
final revised = context.p.reviseFilter(filter);
context.updateRouteParam(value);
}
}
class TodoDetailsScreen extends ... {
...
// it generates this navigate push method:
AFNavigatePushAction navigatePush({
required bool showDetails,
required String filter,
required String todoItemId,
}) {
return AFNavigatePushAction(
launchParam: EditTodoItemScreenRouteParam.create(
showDetails: showDetails,
filter: filter,
todoItemId: todoItemId,
)
);
}
Using these two flags will save you a lot of time writing boilerplate, and will generally tend to preserve the coding conventions that I recommend.
Member Variables vs. Resolve Variables
The distinction between --member-variables
and --resolve-variables
is the same here as it was when generating state in the previous section.
If you want your route parameter to have a direct reference to an object, then place it in the --member-variables
flag. If you want it to have an ID for an object, and resolve that object from your state view dynamically during rendering, then place it in the --resolve-variables
flag.
As I mentioned in the tutorial, as a general rule if you have a screen which modifies data and has an explicit "Save" action, then you will often want to declare the object as a member variable, using --member-variables
. Doing so allows that object to evolve outside your global XXXState
, and if the user cancels the change without saving, leaves your XXXState
without any changes.
If your UI constantly saves changes the user makes, then you are more likely to refer to it using an ID, using --resolve-variables
. Each time you save the object, it will be updated in your global state, and your UI will dynamically use the latest version of the object from your XXXState
.
Adding More Member Variables and Resolve Variables Later
As in the state section above, you can use the generate augment
command to add member or resolve variables to your route parameter later. Note that when you do, you specify the class name of the UI element, not the route parameter.
For example, the command:
Will add the boilerplate for the new hashTag
member variable to the TodoDetailsScreen's route parameter.
Don't Copy/Paste Screens
You might be tempted to copy/paste one screen file to create another screen file. The generate ui
command modifies several files when it creates a new screen. Consequently, I recommend that you always use the generate command for new UI elements.
Configuring the UI Prototype
When you generate a new UI construct, AFib will also create a file with an _tests
suffix.
For example:
Within that file will be a single initial UI prototype. For example:
void _defineQuickStartScreenPrototypeInitial(AFUIPrototypeDefinitionContext context) {
var prototype = context.defineScreenPrototype(
id: XXXPrototypeID.quickStartScreenInitial,
stateView: XXXTestDataID.xxxStateFullLogin,
navigate: QuickStartScreen.navigatePush(),
);
...
}
This prototype is accessable in the 'UI Prototypes' section of prototype mode. If you navigate to the UI prototype in prototype mode, you will be able to see the UI element update in real time each time you modify its buildWithSPI
function and save its source file.
When I begin developing a new screen, the first thing I do is switch to prototype mode, navigate to its UI prototype, and then begin prototyping the actual UI, which updates in real time as I save the dart file.
Configuring State View Data
Inititally AFib links the state view to the xxxStateFullLogin
test data element. This is intended to be a fully-formed sample state for your app. You can also manually create additional full states in your test_data.dart
, and refer to them instead.
However, you can also specify the content of states and state views more flexibly, as described in the next several sections.
How States/StateViews work internally
The key to understanding the flexibility of your state (e.g. XXXState
) and state view (e.g. XXXDefaultStateView
) is understanding that root objects within them are stored in a map by type name. For example, an object with the type UserCredentialRoot
is in the map under the string key "UserCredentialRoot". As a result, given any object, AFib can write it into a state or state view by updating this string key in the map. Given any runtime type, AFib can dynamically find it in the root of the state or state view.
Specifying Only the Root States Your UI Uses
Suppose your app's XXXState
contains 10 different root elements, but the prototype screen in question references only three of them -- SettingsRoot
, UserCredentialRoot
, and UsersRoot
. In that case, you can provide test data identifiers for values of those three types in an array.
...
stateView: [
XXXTestDataID.settingsChris,
XXXTestDataID.userCredentialChris,
XXXTestDataID.usersChris
],
The three values will be merged into a state view, and as long as you only reference the accessors for those root state elements within your UI, your prototype will work properly.
Specifying Objects Instead of Test IDs
Anywhere you can specify a test identifer, you can also specify an object instead. For example, the code above might be rewritten with the UserCredentialRoot
explicitly declared, rather than pulled from the test data.
stateView: [
XXXTestDataID.settingsChris,
UserCredentialRoot(id: XXXTestDataID.userCredentialChris, email: "chris@nowhere.com"),
XXXTestDataID.usersChris,
],
Test data tends to be highly reusable, so I usually place test data in the test_data.dart
file, and reference it using test ids.
Overriding Specific Root Objects
If you specify an array of objects, then they are consolidated into a state view in order.
So, for example, if the first test id
references an instance of XXXState
with 3 todo list items in its TodoItemsRoot
value, and the second an instance of TodoItemsRoot
with 10 todo list items, then the large list will override the smaller list, leaving everything else about XXXState
the same:
stateView: [
// We start with our full sample state, which includes the a TodoItemsRoot with 3 items
XXXTestDataID.xxxStateFullLogin,
// But then we also specify a TodoItemsRoot with 10 items, that object will replace the
// original TodoItemsRoot, but leave all the other root objects from the original state
// unchanged.
XXXTestDataID.todoItemsLongList
],
This ability to override just a few of many root elements in your state view makes it easier to create differentiated test cases without recreating entire states.
Note that you can perform this same sort of override directly in the test data itself, if you wish to reuse the result in several locations:
// this does the same resolution logic that was shown above in the UI prototype, but does it in the
// test data itself.
final listOfRootModels = context.defineRootStateObjectList(XXXTestDataID.listFullLoginWithMoreTodoItems, [
XXXTestDataID.evexStateFullLogin,
// But then we also specify a TodoItemsRoot with 10 items, that object will replace the
// original TodoItemsRoot, but leave all the other root objects from the original state
// unchanged.
XXXTestDataID.todoItemsLongList
]);
// here, we are defining an XXXState containing the resolved root objects.
context.define(XXXTestDataID.stateFullLoginWithMoreTodoItems, XXXState.fromList(listOfRootModels));
Having done so, we can refer to our new state with more todo items in several places, like this:
Configuring the Route Parameter
The prototype definition also takes a navigate parameter, which you will populate using the screen's
static navigatePush
method. That method returns an initial route parameter.
As you begin to build non-trivial screens, your navigatePush method will begin to take function parameters. It will use those parameters to establish the initial state of the screen's route parameter. For example:
AFNavigatePushAction navigatePush({
required TodoItem itemToEdit,
}) {
return AFNavigatePushAction(
launchParam: EditTodoItemScreenRouteParam(
itemToEdit: itemToEdit,
)
);
}
In the prototype definition, you can hard-code these values, or pull them from the test data using identifiers using context.find
, like this:
void _defineEditTodoItemPrototypeInitial(AFUIPrototypeDefinitionContext context) {
var prototype = context.defineScreenPrototype(
id: TDLEPrototypeID.editTodoItemInitial,
stateView: TDLETestDataID.xxxStateFullLogin,
navigate: EditTodoItemScreen.navigatePush(
// You may want to lookup test data in order to pass it into parameters
// of navigate push.
itemToEdit: context.find<TodoItem>(TODOTestDataID.todoRememberTheMilk)
),
);
...
}
Building AFib UI
Now that you have a UI element, your goal is to implement its buildWithSPI
method:
The SPI contains a context
member variable which provides access to the route
parameter and state view for your UI element.
Break Complex UI Down into Local Subprocedures
When I first started using Flutter several years ago, it was common to find online code examples of static constant UI declarations, like this:
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(10),
child: ListView(
children: <Widget>[
Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(10),
child: const Text(
'Your Title',
style: TextStyle(
color: Colors.blue,
fontWeight: FontWeight.w500,
fontSize: 30),
)),
Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(10),
child: const Text(
'Sign in',
style: TextStyle(fontSize: 20),
)),
Container(
padding: const EdgeInsets.all(10),
child: TextField(
controller: nameController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'User Name',
),
),
),
Container(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 0),
child: TextField(
obscureText: true,
controller: passwordController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Password',
),
),
),
TextButton(
onPressed: () {
//forgot password screen
},
child: const Text('Forgot Password',),
),
Container(
height: 50,
padding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
child: ElevatedButton(
child: const Text('Login'),
onPressed: () {
print(nameController.text);
print(passwordController.text);
},
)
),
Row(
children: <Widget>[
const Text('Does not have account?'),
TextButton(
child: const Text(
'Sign in',
style: TextStyle(fontSize: 20),
),
onPressed: () {
//signup screen
},
)
],
mainAxisAlignment: MainAxisAlignment.center,
),
],
));
}
I do not prefer this style, as I find it is often duplicative and difficult to understand and maintain.
Instead, I recommend breaking your UI build into subprocedures, each one taking your SPI as a parameter, similar to this:
Widget buildWithSPI(LoginScreenSPI spi) {
final t = spi.theme; // the theme, explained below.
final rows = t.column(); // just returns a List<Widget>.
rows.add(_buildTitle(spi));
rows.add(_buildEmailSection(spi));
rows.add(_buildPasswordSection(spi));
rows.add(_buildSigninSection(spi));
return ListView(children: rows);
}
When dealing with a list of items, you will often pass the item itself to a subprocedure:
final t = spi.theme;
final rows = t.column();
for(final todoItem in spi.todoItems) {
rows.add(_buildTodoItem(spi, todoItem));
}
return ListView(children: rows);
}
Push data references into the SPI
Although you could access the data in the route parameter and state view directly from the UI code, like this:
Widget buildWithSPI(TodoListScreenSPI spi) {
// here, we are reaching down into the SPI's context member to access the route parameter
// and state view directly from the UI code, this is undesireable.
final context = spi.context;
final routeParam = context.p;
final stateView = context.s;
// this is the kind of code that belongs in your SPI, not in your UI build function.
// it is business logic which combines information from your route parameter and state view.
final todosShowing = stateView.todoItems.findWhere((todo) => todo.satisfiesFilter(routeParam.filter));
// Do this instead, and place the filtering logic above in the todosShowing
// accessor method (see below).
// Note that we now have no need to access the routeParam or stateView
// directly from the UI rendering code, the SPI does it for us.
final correct = spi.todosShowing;
}
Instead, I recommend that your SPI expose a simplified API that your UI uses for rendering, as described below.
Use Accessors for Simple Data
You can use Dart accessors, which look just like member variables to the caller, to expose data from the SPI to your UI. For example, the above code could be re-written in the SPI as:
class TodoListScreenSPI extends ... {
...
Iterable<TodoItem> get todosShowing {
// this can be re-written in a single line using List.where, but I wanted to make
// this code more approachable.
final filter = context.p.filter;
return context.s.todoItems.findWhere((todo) => todo.satisfiesFilter(filter));
}
}
Even direct access to simple member variables in the route param is best encapsulated in an SPI accessor, for example:
As you will avoid the verbose syntax:
// this is awkward
e.expect(spi.context.p.filter, ft.equals(...));
// this is better
e.expect(spi.todoFilter, ft.equals(...));
In your tests, and use spi.todoFilter
instead.
Use Member Variables for Complex Data
Dart and Flutter are extremely fast, and I have never seen repeated calls to accessor methods yield a performance problem. However, when deriving a more complex data structure, I often like to make it a final member variable in the SPI, if only because doing so provides a single clear place to debug the business logic associated with generating the value.
You can do so by adding a member variable to your SPI and populating it in the create
factory function for your SPI, like this:
@immutable
class TodoListScreenSPI extends TDLEScreenSPI<TDLEDefaultStateView, TodoListScreenRouteParam> {
// add a member variable for your derived data.
final List<TodoItem> todosShowing;
TodoListScreenSPI(AFBuildContext<TDLEDefaultStateView, TodoListScreenRouteParam> context, AFStandardSPIData standard, {
// populate it from the constructor
required this.todosShowing,
}) : super(context, standard);
factory TodoListScreenSPI.create(AFBuildContext<TDLEDefaultStateView, TodoListScreenRouteParam> context, AFStandardSPIData standard) {
// generate the value in the factory create method.
// Note that you have access to the context as a
// parameter to this function.
final result = stateView.todoItems.findWhere((todo) => todo.satisfiesFilter(routeParam.filter)).toList();
return TodoListScreenSPI(
context,
standard,
// now, we pass our generated result to the member variable
todosShowing: result,
);
}
Note that the syntax for the UI is the same whether your data is an accessor method or a member variable: spi.todosShowing
in this case.
Use the Functional Theme to Centralize UI Conventions
Use Typeahead to Discover Theme Methods
All SPIs have a theme member variable. During prototyping, the theme allows you to avoid hard-coded values in favor of conceptual values that can be configured from a central location later. For example:
final t = spi.theme; // or spi.t for brevity.
return Container(
// a standard margin discoverable via typeahead by starting with the parameter name.
// see how t.margin... starts the same way as the "margin:" parameter?
margin: t.margin.standard,
// instead of
margin: EdgeInsets.all(8.0),
...
);
Themes are covered in much more detail in a subsequent section, but for now know that they are designed to be accessed via typeahead matching the named parameter you are trying to fill in. For example:
final t = spi.t;
return Container(
decoration: BoxDecoration(
color: t.colorPrimary, // note that 'color:' matches 't.color...'
borderRadius: t.borderRadius.standard, // and 'borderRadius:' matches 't.borderRadius...'
)
...
);
So, I recommend you try using typeahead for values that seem like things you might like to theme, you will often find something useful.
Place Simple Common UI in the Default Theme
I find that there is a large class of UI elements which are shared across many screens in my app, but which feel too small to warrant there own distinct widget classes. Icons are a good example. Your default theme is a good place to put this functionality. Technically, your app can have multiple themes as it grows larger.
When the theme does not already contain a typeahead value for the commonality you wish to express, you can add a method or accessor to it. You will find it in
For example, you might add something like:
class XXXDefaultTheme extends AFFunctionalTheme {
...
// note that I am preserving the typeahead convention here,
// because this will fill in a 'decoration:' parameter.
Decoration get decorationCongratsTitle {
return BoxDecoration(
color: Colors.green,
borderRadius: borderRadius.top.standard,
);
}
}
I find having a convenient place to put common UI useful in prototyping, as it helps avoid duplicated code at a time when the code is likely to be changing quickly, and you'd like to defer decisions about UI conventions (e.g. what icon should we use for editing an item) in a maintainable way. It also makes discovering emerging UI conventions easier (via typahead) if multiple contributors are developing the prototype.
Use the theme's child... methods for common widgets
Note that because many widgets have child widgets, there are many functional theme variants
under child...
. For example:
final t = spi.t;
return Container(
color: t.colorPrimary,
// again, see how t.child... matches the "child:" parameter name?
child: t.childText("Hello World")
);
There are a variety of advantages to using these theme-based methods over using Flutter widgets
directly (e.g. Text("Hello World");
). I recommend you prefer using a theme method if one exists.
Create rows and columns
Although it is used in many examples above, I like the syntax:
Both of these methods do nothing more than return a newList<Widget>
.
Casting from One Theme To Another
Because functional themes are not allowed to have their own data, you can always get one theme from another. For example, if you have your own applications default theme, and wish to access the theme for the AFib Signin library (which you must have integrated, as described below), you could use:
Themes and Internationalization
You can use t.translate(...)
to translate a string in your prototype language, an XXXTranslationID
, or an XXXWidgetID
into a localized String. The translations themeselves are specified within xxx_define_core.dart
as part of the fundamental theme.
Internationalization is covered in the production section.
Themes and Flutter Keys
Native Flutter widgets are identified in the widget hierarchy using the Key
class. You can convert from AFib Widget Ids to Keys using t.keyForWID(XXXWidgetID.buttonWhatever)
.
Note that t.keyForWID
allows the widget ID to be null, in which case it returns a null Key. Some Flutter widgets require a non-null key, in which case you can use t.keyForWIDNotNull
.
Also note that most theme.child
methods take a widget ID directly, so you do not need to convert to a key in those cases.
Implementing SPI Event Handlers
Up to this point, this section has been focused on using the route parameter, state view and theme to construct a set of widgets which display on the screen. Now, we will focus on handling events that occur when the user interacts with your UI. For example:
final t = spi.t;
return FlatButton(
child: t.childText("Increment Counter"),
onPressed: spi.onPressedIncrementCounter,
);
Here, our UI will display a button on the screen, and we want to handle the button press. Our SPI exposes a method that handles the event:
class HomeScreen extends... {
...
void onPressedIncrementCounter() {
// remember, the route parameter is immutable, we have to copy (revise)
// it to alter it.
final revised = context.p.reviseIncrement();
// This will cause a re-render, because our route parameter has changed.
// Within the SPI, think of the context as exposing an API that allows you to
// manipulate AFib's state in a variety of ways.
context.updateRouteParam(revised);
}
}
SPI Event Handlers Will Be Accessible From Testing
Many of our tests will subsequently be written at the SPI level. Moving event handling code into the SPI makes that code easily accessible from those tests later. If we had written the code above this way instead:
final t = spi.t;
return FlatButton(
child: t.childText("Increment Counter"),
onPressed: () {
// this will work just fine, but this code won't be accessible through our fastest
// and most reliable form of testing -- the state test -- because that test builds
// only the SPI, it doesn't actually render the UI.
final context = spi.context;
final revised = context.p.reviseIncrement();
context.updateRouteParam(revised);
}
);
It would have produced the same result, but would have been more difficult to test.
Manipulate AFib's state within the SPI using its context
The SPI's context member variable provides a variety of methods for manipulating AFib's state. Typically, your event handlers will invoke one or more of these methods.
The typeahead prefixes you will most often use are:
- context.update...
- Update a route parameter, or occasionally update the global component state, but that is more often done via a query.
- context.navigate...
- Navigate to a new screen, updating AFib's route state
- context.show...
- Show a dialog, bottom sheet, or drawer, updating AFib's route state.
- context.execute...
- Execute a query, which will usually in turn update the global componnent state.
Use Typeahead to Name/Access SPI Event Handlers
I recommend you use the same typeahead convention for naming SPI event handlers that you do with themes. Start them with the name of the parameter they are supplied to:
Maintaining Widget IDs
Creating Widget IDs (and other Ids) in VSCode
AFib provides a simple VSCode extension which makes maintaining the (perhaps too copious) IDs used in AFib easier. It is particularly useful for XXXWidgetID
and XXXTestDataID
values, which you will create frequently. To use it, type the full name of your desired widget id directly into your code:
Your new widget id will initially not exist, yielding a compiler error. Then hit Command-Shift-P, and select "Fixup AFib Identifier(s)" AFib will add a constant identifier to your XXXWidgetID
class, resolving the compiler error.
AFib Widget IDs are declared like this:
While Test Data IDs are declared as simple strings, like this:
If you refactor an ID in your code, the symbol name will update, but the string value will remain unchanged, causing the two to be out-of-sync. Leaving them out-of-sync is confusing for testing and debugging purposes.
You can fix that issue by opening your lib/xxx_id.dart
file, and running the "Fixup AFib Identifier(s)" command within it. It will synchronize all your ids, reporting the results in the Output panel.
Differentiating Widget IDs
In lists of objects, you will often need to append an object identifier to a widget ID to produce a unique value. You can do so using the with1
, with2
and with3
methods. For example, in this UI generation code:
for(todoItem in spi.todoItems) {
rows.add(TodoItemWidget(
wid: TDLEWidgetID.widgetTodo.with1(todoItem.id),
// if the widget does not modify the todo item, its best to pass in only the
// id, so that it can late bind to the actual todo item by accessing it from the
// stateView.
todoId: todoItem.id,
));
}
_${todoItem.id}
. If the actual todo items in the list were test data with the IDs XXXTestDataID.rememberTheMilk
and XXXTestDataID.mowTheLawn
, then the resulting widget ids/Flutter Keys would be widgetTodo_rememberTheMilk
and widgetTodo_mowTheLawn
, which can be useful in debugging. In production code, it might be something like widgetTodo_43526
if you database uses integer ids.
Access the Full Current State in an Event Handler
Your UI generation code should always render itself using data from the route parameter and state view found in the spi.context
, as your UI re-renders when either of those values change. If you render your UI using data from outside those two values, the UI will not re-render automatically.
In most cases, the state necessary to implement an event handler will also be found in the route parameter and state view.
However, in rare cases (especially when overriding an event handler for a third party component), you may need access to the entire AFib state. You can access the entire AFib public state in an event handler using:
final eventContext = context.accessOnEventContext();
final xxxState = eventContext.accessComponentState<XXXState>();
/// now you can access any root state object, you can also access state
/// of third-party afib libraries.
Note that if you have a navigatePush
method which needs access to a signficant subset of your root state, you can simplify your code by creating a navigate push function that takes the AFOnEventContext
:
context.navigatePush(ComplicatedScreen.navigatePush(
eventContext: context.accessOnEventContext(), // inside navigatePush, you can access whatever you want.
...
));
However, when you do this, you will need access to an AFOnEventContext
in your UI prototype's declaration, you can access on using the navigateWithEventContext
parameter:
var prototype = context.defineScreenPrototype(
id: EVEXPrototypeID.complicatedScreenInitial,
stateView: EVEXTestDataID.evexStateFullLogin,
navigateWithEventContext: (eventContext) {
return ComplicatedScreen.navigatePush(
eventContext: eventContext,
...
);
},
);
In the UI prototype context, the eventContext.accessComponentState
calls will work, but they will only have access to the root objects defined in your stateView
parameter.
Working With Flutter State in AFib
Several major Flutter Widgets -- for example the text editing control, scrolling controls, focus controls, and various gesture recognizers - are stateful. A screen which contains these widgets needs to preserve these widgets' states for the duration of the screen's existence.
AFib route parameters already last for the duration of the screen. Consequently, the route parameter is the correct location to host the state for stateful flutter widgets.
AFib itself provides a utlity class, AFFlutterRouteParamState
, which makes it easy to manage references to the major Flutter widget state types. It also provides a route parameter parent class -- AFRouteParamWithFlutterState
-- which has a member variable of that type, and which automatically calls dispose on any Flutter widget state you create.
Specifying Flutter State in generate ui
All the
variants support a --with-flutter-state
flag. If you use the flag when generating UI, much of the code below will be included for you. It is still worth reading the subsequent sections so that you understand why the generated code works as it does.
Preserve Flutter State in copyWith
The Flutter state should exist as long as the screen does, so there is no need to copy or modify it in your copy with method. Instead, you will just pass the existing Flutter state through:
void copyWith({
User? user,
// note that there is no need to have a parameter for the flutter state.
}) {
return EditUserScreenRouteParam(
// the normal thing, modify the user iff it was supplied.
user: user ?? this.user,
// but the flutter state gets preserved for the life of the route parameter:
// (the Guaronteed suffix is necessary to assure Dart that the flutterState is non-null)
flutterState: this.flutterStateGuaranteed,
)
}
Editing Simple State, like Focus Controllers
In most cases, to manage Flutter state in a route param, you declare an AF...Holder
class, where the ...
is the name of the type of state, and pass it to the AFFlutterRouteParamState
, which is in turn part of the route parameter:
final focusNodes = AFFocusNodesHolder();
final flutterState = AFFlutterRouteParamState(
focusNodes: focusNodes
);
Then, in your UI code, you access the desired focus node using the widget id for your widget:
The AF...Holder
class hides the messy details, and insures that any flutter state'sdispose
method gets called when the route parameter is disposed.
Special Provisions for Managing Text Edit State
I repeatedly tried to simplify the management of Flutter state for Flutter's TextEdit field, with limited success. Although it is not a meaningful issue for developer productivity, the complexity associated with managing TextEdit fields is one of my biggest frustrations with AFib.
The follow sections describe the steps necessary to properly use a TextEdit field in AFib.
Initialize The State In Your Route Parameter
Your route parameter should derive from AFRouteParamWithFlutterState
, like this:
class EditUserScreenRouteParam extends AFScreenRouteParamWithFlutterState {
final User user;
StartupScreenRouteParam({
required this.user,
required AFFlutterRouteParamState flutterState,
}) : super(screenId: TDLEScreenID.startup, flutterState: flutterState);
...
Note that the AFFlutterRouteParamState
gets passed to the parent class, which manages it. Then, within navigate push, you will need to initialize the AFFlutterRouteParamState
:
static AFNavigatePushAction navigatePush({
required User user,
}) {
// note that TDLEWidgetID.editName will also be used when we declare the widget,
// and that the initial value in the text edit should be user.name, since we initialized
// the controller that way. There is a createN variant if you have many text edits.
final textControllers = AFTextEditingControllers.createOne(TDLEWidgetID.editName, user.name);
// the flutterState can have more than just textControllers (e.g. focusNodes, etc)
final flutterState = AFFlutterRouteParamState(
textControllers: textControllers,
);
return AFNavigatePushAction(
launchParam: StartupScreenRouteParam.create(
user: user,
// note that the flutterState is part of the route parameters, and will live for as
// long as it does.
flutterState: flutterState,
),
);
...
Note that we have created an AFTextEditingControllers
object which associates the TDLEWidgetID.editName
widget id with the user's name value. AFTextEditingControllers
has a createN
method if you have multiple text editing fields.
Use the Theme Utility to Create the Text Field
AFib provides a utility function in your default theme to create a TextField:
final t = spi.t;
final rows = t.column();
rows.add(t.childTextField(
// screenId is a member variable of the screen itself.
screenId: screenId,
// note that this matches the widget id you used in the route parameter declaration
wid: TDLEWidgetID.editName,
// allows the utility to access the flutterState
parentParam: spi.context.p,
// handles editing, shown below.
onChanged: spi.onChangedName,
// this is not required, but I highly recommend it, as it allows an assertion to catch
// many bugs where the state in the widget itself gets out of sync with the state in your
// route parameter.
expectedText: spi.user.name,
));
All this complexity is really frustrating to me!
Regardless, the text field will use the TextEditingController which was declared for TDLEWidgetID.editName
as part of the route parameter. I strongly recommend you set the optional expectedText
parameter to the model value that correlates to the text field. AFib will assert false if the two get out of sync.
Synchronize the TextEdit in the SPI
In one final frustrating piece of complexity, you must call context.updateTextField
as shown below when the text field changes.
void onChangedName(String name) {
// here is the final frustrating complexity: In the vast majority of cases this does
// nothing, but if you omit it you will get assertion failures in state tests in UI mode.
context.updateTextField(TDLEWigetID.editName, name);
// this part is expected
final revised = context.p.reviseUserName(name);
context.updateRouteParam(revised);
}
Doing so is necessary because in state tests run from the UI, you may manipulate the value in the route parameter repeatedly without building the UI. Because the UI is never built/manipulated, the value in the TextEditingController
is never set (as it would be in the normal course of events if the UI was built).
When the UI for the final state of the state test does get built, the value within the TextEditingController
will be empty, rather than being in sync with your model value, and an assertion will fail reporting it is out-of-sync.
So, the context.updateTextField
call does nothing in production code, but in state tests in synchronizes the TextEditingController
with the model value, so that when the UI does get built, the two match.
Working with AFib Widgets
AFib widgets are different from screens, dialogs, drawers and bottom sheets for several reasons:
-
You construct the widgets inline in your UI rendering code, rather than allowing AFib itself to construct them for you. As a result, you can pass parameters directly to the constructor.
-
Each widget has its own route parameter, which is often stored in a special set of child parameters for an existing screen. As a result, a widget's route parameter must usually be addressed using both the id of the screen it appears on and its own unique widget id within that screen.
-
Because Widgets have their own state, and because you pass them a 'launch parameter' explicitly, the behavior of the launch parameter is potentially more confusing than when you launch a screen with a route parameter.
Due to these complexities, I recommend you get comfortable will building screens, dialogs, etc before you begin building widgets.
Why Should I use AFib Widgets?
AFib widgets are useful for two (and in rare cases maybe a third) reasons:
-
They facilitate code re-use and UI consistency across screens, while maintaining all the AFib conventions you already know (the SPI, the route parameter and state view, etc).
-
They make it practical to extend third-party libraries in ways specific to your app. I will cover this in more detail in the 'third party' section, but if you ever find yourself wishing to extend a third partly library, but need access to your own app-specific route parameter and state view data, AFib widgets provide a solution.
-
Finally, you can use AFib widgets to localize updates to a single portion of a screen, rather than re-rendering the entire screen. However, this is almost never necessary, and should not be done unless you have a known performance problem (I have yet to encounter a case where this is necessary).
If You Want to Share Code Between Screen SPIs, Consider a Child Widget
If you find yourself wanting to share code between SPIs, the solution is often a child widget. A child widget shares both the UI and the SPI methods it accesses and manipulates across multiple screens. If you still want to share code across SPIs, you can often do so using mixins.
Passing Data to AFib Widgets: Three Cases
You can pass data to an AFib widget via direct constructor parameters, or via data within the launch parameter. In order to understand how data should be passed, you need to understand that in the most productive form of testing (state testing), your UI code will never be executed. As a result, anything you place in a direct constructor parameter will be invisible to your state tests.
There is a correct way to pass information for each of these three scenarios:
-
Direct UI Parameter: Pure UI Parameters - This is the easiest case. If you are passing data that is purely related to the display of the widget -- the margin, the background color -- you can pass it as a direct constructor parameter for a final member variable of the widget. You do not need to test these values in state tests anyway.
-
Direct UI Parameter: Event Handler Methods - An AFib widget will still want to delegate event handlers to its parent screen in some cases. Event Hander methods should also be passed as direct constructor parameters. This may seem counterintuitive, as the event handler will clearly be needed in the state test, but it will usually be available via the SPI of the parent screen in the state test.
-
Route Parameter: Data the Widget Will Own and Mutate Itself - Suppose your
TodoItemWidget
has an expanded or contracted state, which will be controlled via a boolean value in its route parameter. The widget itself owns this data, and has an icon that switches between the states. Data that is controlled and modified by the widget itself like this belongs in the route parameter for the widget.
Mechanisms for Creating Child Parameters
Each screen has a single pool of child parameters. The pool is indexed by the parameter's widget id. Consequently, all AFib-aware widgets on a page must have a unique widget id.
These parameters can be created in several ways.
Reference an Existing Parameter
In some cases, an AFib widget may reference an existing route parameter rather than having its own entry in the child parameter pool. Two common examples include:
- Reference the parent screen parameter - Sometimes, a widget simply wants to 'pull down' the route parameter of its parent screen. This can happen when a widget comprises the majority of the screen, but is also re-used in other contexts. You can achieve this using an
AFRouteParamRef
, and the special flag valueAFUIWidgetID.useScreenParam
:
rows.add(MyChildWidget(
launchParam: AFRouteParamRef(
// screenId is a member variable of the screen.
screenId: screenId,
wid: AFUIWidgetID.useScreenParam,
// this is typically where a screen parameter would be.
routeLocation: AFRouteLocation.screenHierarchy,
),
// note that the widget id is not unique above. To specify a unique widget id
// (e.g. as the key in a list), you can use widOverride.
widOverride: TDLEWidgetID.widgetMyChild,
));
- Reference a global route parameter - You may want a widget to retain its route parameter across screens. You can achieve that using AFRouteParamRef, and a global route parameter you have previously established (perhaps in your startup query):
rows.add(GlobalCounterWidget(
launchParam: AFRouteParamRef(
// You could just reference a screen-level global route parameter, or you could add
// a child widget.
screenId: TDLEScreenID.screenGlobalCounter,
wid: TDLEWidgetID.widgetGlobalCounter,
// This is the important part, it is stored in the global pool.
routeLocation: AFRouteLocation.globalPool,
),
));
Reference a Child Route Parameter
Child route parameters are those that exist in a screen's child route parameter pool. They exist only as long as their parent screen does.
You can create child route parameters as part of the screen's navigatePush method, or inline in
the UI code itself by specifying them directly as the widget's launchParam
. I strongly prefer the former, as it is more consistent with the state driving the UI (rather than the UI driving the state). However, sometimes creating child route parameters in the navigatePush
method is tedious, and specifing them as the widget's launchParam
is justified.
- Specifying Child Route Parameters in Navigate Push
The AFNavigatePushAction
action for a screen takes a children
parameter, which is an array of route parameters. Any parameters in the children
parameter will be placed in the new screen's child parameter pool.
For example, you might create a child route param for each TodoItem
on a page:
class TodoItemListScreen extends ... {
...
static AFNavigatePushAction navigatePush({
required TodoItemsRoot items
}) {
final todoItemParams = <AFRouteParam>[];
for(final item in items.findAll) {
todoItemParams.add(TodoItemWidgetRouteParam(
screenId: TDLEScreenID.todoItemList,
// make the widget id unique.
wid: TDLEWidgetID.todoItem.with1(item.id),
routeLocation: AFRouteLocation.screenHierarchy,
// you might pass this in, although unless it is being edited by the widget
// I would prefer to pass in only its ID, and look it up in the state view.
// The later option will cause it to update dynamically if a push update for
// it comes through, while specifying it directly into the route parameter will
// prevent this automatic update (probably desirable if it is being edited,
// but noth otherwise)
item: item,
// this is data the widget itself will own and modify.
showingDetails: false,
))
}
return AFNavigatePushAction(
launchParam: TodoItemListScreenParam(),
// all the todo item params will be in the child param pool.
children: todoItemParams
);
}
I greatly prefer this method. All the child route parameters are established at the time of navigation. You can see them in the debugger immediately after the navigation occurs.
If you use this method, your widget launch parameter will simply refer to the child parameter that already exists:
for(final item in spi.todoItems) {
rows.add(TodoItemWidget(
launchParam: AFRouteParamRef(
screenId: screenId,
// note that this wid matches the wid on the route parameter that was created during
// navigation.
wid: TDLEWidgetID.todoItem.with1(item.id),
routeLocation: AFRouteLocation.screenHierarchy,
)
))
}
- Generating the Child Route Parameter During the UI Build
I really dislike generating state within the UI code, but it is a workable option and is sometimes more convenient than pre-creating all the child route parameters. This is especially true when you are introducing your own widgets into a third party UI by overriding the third party UI's theme. To do this, you would merge the two code examples above, and simply build an actual launch param directly in the UI code:
for(final item in spi.todoItems) {
rows.add(TodoItemWidget(
// the launch param is specified directly in the UI code, rather than
// creating it in navigatePush, and referring to it with an AFRouteParamRef
// in the launch param.
launchParam: TodoItemWidgetRouteParam(
screenId: TDLEScreenID.todoItemList,
wid: TDLEWidgetID.todoItem.with1(item.id),
routeLocation: AFRouteLocation.screenHierarchy,
item: item,
showingDetails: false,
)
))
}
The Launch Param is Only Certain to Work on First Launch!
Specifying an actual route parameter directly in the UI code can be confusing. Suppose our TodoItemWidgetRouteParam
above had a showDetails
parameter, and the parent screen has an icon which shows the details of all todo items. You might end up with something like this.
for(final item in spi.todoItems) {
rows.add(TodoItemWidget(
launchParam: TodoItemWidgetRouteParam(
screenId: TDLEScreenID.todoItemList,
// make the widget id unique.
wid: TDLEWidgetID.todoItem.with1(item.id),
routeLocation: AFRouteLocation.screenHierarchy,
// you might pass this in, although unless it is being edited by the widget
// you could just look it up in the state view using the item.id.
item: item,
// This looks good, and it will initially work, but as soon as the widget modifies
// its own route parameter, this launchParam value will no longer be used, and changes
// passed in here will no longer be relevant.
showDetails: spi.shouldShowDetailsForAll,
)
))
}
The problem is that once the child widget itself calls context.updateRouteParameter
, it creates a copy of the route parameter in the state that supersedes the launchParam value specified during the UI build.
As a result, the 'showDetails' value specified in the launchParam
will be respected, up until the child widget changes its own route parameter, at which point it will be ignored.
You should try to think of the launch param as an initial value that is subsequently owned by the child widget. You should not imagine that you can pass ongoing updates to the widget via the launchParam value during rendering.
Always create the launch param via an SPI Method
I will explain this point more deeply in the state testing section, but for now, the launch parameter used in your UI code should always be created within your SPI. I did not do this in the examples above for simplicity, but the Todo example above should be written:
for(final item in spi.todoItems) {
rows.add(TodoItemWidget(
// note that I preserve the typeahead convention here.
launchParam: spi.launchParamForItem(item);
))
}
This is because in state tests your will need to provide the launch parameter when referencing a child widget, and you will have access to the SPI, but will not execute the UI build itself. Placing creation of the launch parameter in the SPI of the parent screen makes it easily accessible from the state test, and will subsequently allow you to build and manipulate the SPI of the child widget in your test.
Screen Lifecycle Methods
All screens and widgets have some calls that you can override. I very rarely find that I need to do this, but the methods are:
void onInit(Store store)
This method is called when your widget is first displayed.
void onDispose(Store store)
This method is called when your widget is going out of scope. Be sure to call super.onDispose
.
void onInitialBuild(AFBuildContext context)
This method is called after the widget is built for the first time.
void onWillChange(AFBuildContext previous, AFBuildContext next)
This method is called before the widget is re-rendered in response to a state change.
void onDidChange(AFBuildContext previous, AFBuildContext next)
This method is called after the widget is re-rendered in response to a state change.