Controllers
Controllers are the bridge between XML fragments and business logic. Each controller manages state, loads data, and publishes values and methods that the fragment's expressions can reference.
Creating a Controller
Controllers extend the Controller base class. By convention, controllers
live in lib/xwidget/controllers/:
import 'package:xwidget/xwidget.dart';
class CounterController extends Controller {
var count = 0;
@override
void bindDependencies() {
dependencies.setValue("count", count);
dependencies.setValue("increment", increment);
}
void increment() {
dependencies.setValue("count", ++count);
}
}
Lifecycle
Every controller passes through six stages:
- Creation — the registered factory produces an instance.
- Guard —
guard()evaluates preconditions. Afalseresult short-circuits the lifecycle: the remaining stages are skipped andonGuardFailed()supplies the rendered widget. - Initialization —
init()performs setup work such as fetching data. - Dependency binding —
bindDependencies()publishes data and methods to the fragment. - Child inflation — XML children are inflated against the bound dependencies.
- Build — the final widget tree is rendered.
The guard() Method
guard() runs before init() and determines whether the controller should
proceed. Returning true allows the lifecycle to continue; returning false
bypasses initialization entirely and delegates rendering to onGuardFailed().
The method accepts both synchronous and asynchronous implementations — return
a plain bool for simple checks, or a Future<bool> when the decision
requires a network call or other async work.
class AdminController extends Controller {
bool _isAdmin = false;
@override
Future<bool> guard() async {
final user = await api.getCurrentUser();
_isAdmin = user?.role == 'admin';
return _isAdmin;
}
@override
Widget? onGuardFailed() {
return const Center(child: Text('Admin access required'));
}
}
The default implementation returns true, preserving backward compatibility
with controllers that do not need guard logic.
Typical applications include:
- Authentication — verifying a session and redirecting to a login page on failure.
- Role-based access — restricting content to users with the required role.
- Feature gating — substituting a placeholder when a feature flag is off.
- Maintenance mode — displaying a maintenance notice when a health check fails.
- Version enforcement — prompting for an update when the client is out of date.
The onGuardFailed() Method
When guard() returns false, the framework calls onGuardFailed() to
obtain the widget that should appear in place of the controller's normal
content. Returning null renders an empty SizedBox.shrink.
Because this method executes during the build phase, it is also the appropriate place to schedule post-frame side effects such as navigation redirects:
@override
Widget? onGuardFailed() {
WidgetsBinding.instance.addPostFrameCallback((_) {
XWidget.navigateToFragment(
'login',
dependencies,
context: context,
action: NavigatorAction.pushAndRemoveAll,
);
});
return null;
}
The init() Method
Called once after guard() succeeds. Override this method to perform one-time
setup such as loading data or establishing connections:
class ProductsController extends Controller {
List<Product> products = [];
@override
Future<void> init() async {
products = await api.fetchProducts();
}
}
init() can return void for synchronous initialization, a Future for
async work (which shows the progressWidget while loading), or a Stream
for streaming data.
The bindDependencies() Method
Called at the beginning of each build cycle to publish data and methods to the fragment's expression scope. Values set here become available to all XML expressions within the controller's children.
class ProductsController extends Controller {
List<Product> products = [];
@override
Future<void> init() async {
products = await api.fetchProducts();
}
@override
void bindDependencies() {
dependencies.setValue("products", products);
dependencies.setValue("totalCount", products.length);
dependencies.setValue("refresh", refresh);
}
Future<void> refresh() async {
products = await api.fetchProducts();
setState(() {});
}
}
Calling setState() triggers a rebuild of the controller's child widgets,
similar to how StatefulWidget works in Flutter.
Using Controllers in Fragments
Wrap a section of your fragment with the <Controller> element to associate
it with a controller. The controller's dependencies become available to all
child elements:
<Controller name="CounterController">
<ValueListener varName="count">
<Text data="${toString(count)}"/>
</ValueListener>
<Button onPressed="${increment}">
<Text>Increment</Text>
</Button>
</Controller>
Attributes
| Attribute | Required | Description |
|---|---|---|
name |
Yes | Controller class name |
for |
No | Named slot in the parent widget to render into |
errorWidget |
No | Widget to display if init() throws an error |
progressWidget |
No | Widget to display while init() is running (async) |
options |
No | A map of key-value options passed to the controller |
keepAlive |
No | Requests that keep-alive-aware parents preserve the controller state |
The for Attribute
Use for to assign the controller to a named slot in the parent widget:
<MaterialApp>
<Controller for="home" name="HomePageController">
<!-- home page content -->
</Controller>
</MaterialApp>
The keepAlive Attribute
Set keepAlive="true" when a controller is hosted inside a parent that can
preserve off-screen children, such as a PageView. This lets the controller
request that its State stay alive instead of being disposed and recreated
when the page moves off screen:
<PageView>
<Controller name="OverviewController" keepAlive="true">
<!-- overview page content -->
</Controller>
<Controller name="SettingsController" keepAlive="true">
<!-- settings page content -->
</Controller>
</PageView>
This is useful for page-level controllers that load data, hold scroll
position, or manage local form state. keepAlive does not make the controller
global or persistent outside the widget tree; if the parent route or the
controller itself is removed, the controller can still be disposed.
Error and Progress Widgets
During asynchronous execution of guard() or init(), the controller
displays the progressWidget. If either method throws, the errorWidget
appears instead. Note that a guard() returning false is a controlled
denial, not an error — it triggers onGuardFailed() rather than the
errorWidget:
<Controller name="ProductsController"
progressWidget="${CircularProgressIndicator()}"
errorWidget="${Text('Failed to load')}">
<!-- children rendered after init() completes -->
</Controller>
The options Attribute
Use options to pass configuration to the controller without adding it to
the global dependencies. Access them in the controller via this.options:
class ProductsController extends Controller {
@override
Future<void> init() async {
final category = options['category'];
products = await api.fetchProducts(category: category);
}
}
Registering Controllers
Controllers must be registered before use. Code generation is the recommended approach. The generator scans your controller files and produces a registration function automatically.
Using Code Generation (Recommended)
Run the generator:
This produces a registerXWidgetControllers() function. In normal apps, the
generated registry calls it for you. Pass registerXWidgetComponents to
XWidget.initialize():
import 'package:xwidget/xwidget.dart';
import 'xwidget/generated/registry.g.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await XWidget.initialize(register: registerXWidgetComponents);
runApp(const MyApp());
}
If you need custom registration logic, call registerXWidgetControllers() from
the register callback you pass to XWidget.initialize(). Controllers must be
registered before resources are loaded, because resources can inflate fragments
that reference controllers.
See Controller Code Generation for configuration details.
Manual Registration
You can also register controllers manually: