Skip to content

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:

  1. Creation — the registered factory produces an instance.
  2. Guardguard() evaluates preconditions. A false result short-circuits the lifecycle: the remaining stages are skipped and onGuardFailed() supplies the rendered widget.
  3. Initializationinit() performs setup work such as fetching data.
  4. Dependency bindingbindDependencies() publishes data and methods to the fragment.
  5. Child inflation — XML children are inflated against the bound dependencies.
  6. 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.

Run the generator:

$ xc generate --only controllers

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:

XWidget.registerControllerFactoryForName(
  'ProductsController',
  () => ProductsController(),
);