Skip to content

Support one-time driver events (side effects) for navigation, snackbars, and dialogs #186

@PaulPWalter

Description

@PaulPWalter

Is your feature request related to a problem? Please describe.

Currently, WidgetDriver only provides notifyWidget() to communicate from a driver to its widget. This works well for state — persistent data that drives the UI on every build. However, there is no built-in mechanism for one-time side effects like:

  • Showing a SnackBar or toast after an action completes
  • Triggering navigation (push a route, pop, open a deep link)
  • Displaying an error dialog
  • Playing haptic feedback or a sound

Today, the workaround is to model these as state with manual "consumed" flags, which is error-prone and clutters the driver:

// --- Driver ---
@GenerateTestDriver()
class CheckoutPageDriver extends WidgetDriver {
  late final PaymentService _paymentService;

  // ❌ Workaround: model one-time event as state with a consumed flag
  String? _errorMessage;
  bool _shouldNavigateToSuccess = false;

  @TestDriverDefaultValue()
  String? get errorMessage => _errorMessage;

  @TestDriverDefaultValue(false)
  bool get shouldNavigateToSuccess => _shouldNavigateToSuccess;

  @TestDriverDefaultValue()
  void clearError() {
    _errorMessage = null;
    notifyWidget();
  }

  @TestDriverDefaultValue()
  void clearNavigation() {
    _shouldNavigateToSuccess = false;
    notifyWidget();
  }

  @TestDriverDefaultValue()
  Future<void> submitPayment() async {
    final result = await _paymentService.pay();
    if (result.isSuccess) {
      _shouldNavigateToSuccess = true;
    } else {
      _errorMessage = result.error;
    }
    notifyWidget();
  }
}

// --- Widget ---
class CheckoutPage extends DrivableWidget<CheckoutPageDriver> {
  @override
  Widget build(BuildContext context) {
    // ❌ Must check and clear flags on every build — easy to forget, races possible
    if (driver.shouldNavigateToSuccess) {
      driver.clearNavigation();
      WidgetsBinding.instance.addPostFrameCallback((_) {
        Navigator.of(context).pushReplacementNamed('/success');
      });
    }
    if (driver.errorMessage != null) {
      final msg = driver.errorMessage!;
      driver.clearError();
      WidgetsBinding.instance.addPostFrameCallback((_) {
        ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
      });
    }

    return ElevatedButton(
      onPressed: driver.submitPayment,
      child: Text('Pay'),
    );
  }

  @override
  WidgetDriverProvider<CheckoutPageDriver> get driverProvider => $CheckoutPageDriverProvider();
}

Problems with this approach:

  1. One-time effects are modeled as persistent state — semantically wrong
  2. Requires manual "consumed" flag management (clear after read)
  3. Checking state in build() for side effects is fragile (can trigger multiple times on rebuilds)
  4. Clutters both driver and widget with boilerplate
  5. addPostFrameCallback hacks needed to avoid building during build

Describe the solution you'd like

A DriverEvents<E> mixin for drivers and a corresponding EventDrivableWidget<Driver, Event> base class for widgets that provides a clean, type-safe channel for one-time events:

// --- Events ---
sealed class CheckoutEvent {}
class NavigateToSuccess extends CheckoutEvent {}
class ShowError extends CheckoutEvent {
  final String message;
  ShowError(this.message);
}

// --- Driver ---
@GenerateTestDriver()
class CheckoutPageDriver extends WidgetDriver with DriverEvents<CheckoutEvent> {
  late final PaymentService _paymentService;

  @TestDriverDefaultValue()
  Future<void> submitPayment() async {
    final result = await _paymentService.pay();
    if (result.isSuccess) {
      emitDriverEvent(NavigateToSuccess());
    } else {
      emitDriverEvent(ShowError(result.error));
    }
  }
}

// --- Widget ---
class CheckoutPage extends EventDrivableWidget<CheckoutPageDriver, CheckoutEvent> {
  @override
  void onDriverEvent(CheckoutEvent event, BuildContext context) {
    switch (event) {
      case NavigateToSuccess():
        Navigator.of(context).pushReplacementNamed('/success');
      case ShowError(:final message):
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(message)),
        );
    }
  }

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: driver.submitPayment,
      child: Text('Pay'),
    );
  }

  @override
  WidgetDriverProvider<CheckoutPageDriver> get driverProvider => $CheckoutPageDriverProvider();
}

Key properties:

  • Type-safe: onDriverEvent is typed to the driver's event sealed class — exhaustive switch
  • Buffered: Events emitted during didInitDriver() are queued and delivered once the widget subscribes
  • Non-breaking: DrivableWidget is unchanged; opt-in via EventDrivableWidget subclass + DriverEvents mixin
  • Testable: Generated test drivers get no-op event stubs; DriverTester gets waitForDriverEvent<E>() helper
  • Clean separation: State (notifyWidget) vs. effects (emitDriverEvent) are architecturally distinct

Describe alternatives you've considered

1. How other mobile frameworks solve this

This is a well-established pattern across mobile development:

  • Android (Kotlin + Jetpack Compose): ViewModels expose a Channel<UiEvent> converted to Flow via receiveAsFlow(). The UI collects it in LaunchedEffect { viewModel.events.collect { handleEvent(it) } } — executed outside the composition/render cycle. Google's guidance suggests modeling as consumable state, but the community overwhelmingly prefers the Channel/Flow approach.

  • iOS (Swift + SwiftUI): ViewModels expose an AsyncStream<Event> or Combine PassthroughSubject<Event, Never>. SwiftUI collects via .task { for await event in vm.events { ... } }. Many teams also use a separate Coordinator/Router pattern for navigation.

Both ecosystems clearly distinguish state (drives UI rendering) from effects/events (fire-and-forget, consumed once).

2. flutter_bloc and bloc_presentation

flutter_bloc — the most popular state management in Flutter — has the same limitation. BlocListener reacts to state changes, but one-time events that shouldn't persist in state have no first-class mechanism.

This gap led to bloc_presentation — a community extension that adds exactly this feature to Bloc/Cubit via BlocPresentationMixin + BlocPresentationListener. From their README:

Use presentation events instead of state when you need to: show a SnackBar, toast, or dialog after an action; trigger navigation; fire analytics events; play a sound or haptic feedback — any fire-and-forget side effect that isn't part of your UI state.

The fact that an entire separate package was needed for flutter_bloc confirms this is a real architectural gap. We can solve it natively in widget_driver.

3. Callback-based approach (passing functions from widget to driver)

Instead of events, the widget could pass callback functions (onSuccess, onError) to the driver. This creates tight coupling, makes testing harder, and doesn't work well with the code generation model.

4. Modeling everything as state with consumed flags

The current workaround (described above). Error-prone, verbose, semantically incorrect.


Additional context

The implementation approach mirrors bloc_presentation's design:

  • DriverEvents<E> mixin ≈ BlocPresentationMixin
  • EventDrivableWidget.onDriverEventBlocPresentationListener.listener
  • emitDriverEvent(event)emitPresentation(event)

But integrated directly into widget_driver's architecture — no separate listener widget needed, the event handling lives in the widget itself via onDriverEvent, matching the existing DrivableWidget.build() pattern.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions