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:
- One-time effects are modeled as persistent state — semantically wrong
- Requires manual "consumed" flag management (clear after read)
- Checking state in
build() for side effects is fragile (can trigger multiple times on rebuilds)
- Clutters both driver and widget with boilerplate
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.onDriverEvent ≈ BlocPresentationListener.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.
Is your feature request related to a problem? Please describe.
Currently,
WidgetDriveronly providesnotifyWidget()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:Today, the workaround is to model these as state with manual "consumed" flags, which is error-prone and clutters the driver:
Problems with this approach:
build()for side effects is fragile (can trigger multiple times on rebuilds)addPostFrameCallbackhacks needed to avoid building during buildDescribe the solution you'd like
A
DriverEvents<E>mixin for drivers and a correspondingEventDrivableWidget<Driver, Event>base class for widgets that provides a clean, type-safe channel for one-time events:Key properties:
onDriverEventis typed to the driver's event sealed class — exhaustive switchdidInitDriver()are queued and delivered once the widget subscribesDrivableWidgetis unchanged; opt-in viaEventDrivableWidgetsubclass +DriverEventsmixinDriverTestergetswaitForDriverEvent<E>()helpernotifyWidget) vs. effects (emitDriverEvent) are architecturally distinctDescribe 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 toFlowviareceiveAsFlow(). The UI collects it inLaunchedEffect { 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 CombinePassthroughSubject<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.
BlocListenerreacts 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: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 ≈BlocPresentationMixinEventDrivableWidget.onDriverEvent≈BlocPresentationListener.listeneremitDriverEvent(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 existingDrivableWidget.build()pattern.