Skip to main content

What are surface observers?

Surface observers are widgets that watch a specific surface slot and react to active item changes. They provide a simple, reactive way to build custom UI that responds to Presentum state without the overhead of popup-specific features. The PresentumActiveSurfaceItemObserverMixin handles all the complexity of:
  1. Subscribing to surface state - Automatically observes the correct slot
  2. Tracking active item changes - Detects when items activate or deactivate
  3. Managing lifecycle - Cleans up observers when widget disposes
  4. Handling initial state - Optionally processes the initial active item
Use surface observers for snackbars, banners, overlays, animations, or any UI that needs to react to surface state changes without showing dialogs.

Basic usage

Here’s a minimal surface observer:
class CustomSurfaceHost extends StatefulWidget {
  const CustomSurfaceHost({required this.child, super.key});
  final Widget child;

  @override
  State<CustomSurfaceHost> createState() => _CustomSurfaceHostState();
}

class _CustomSurfaceHostState extends State<CustomSurfaceHost>
    with PresentumActiveSurfaceItemObserverMixin<
      CampaignItem,
      AppSurface,
      CampaignVariant,
      CustomSurfaceHost
    > {

  @override
  AppSurface get surface => AppSurface.banner;

  @override
  void onActiveItemChanged({
    required CampaignItem? current,
    required CampaignItem? previous,
  }) {
    // React to state changes
    if (current != null) {
      // New item became active
      print('Showing: ${current.id}');
    } else if (previous != null) {
      // Active item became inactive
      print('Hiding: ${previous.id}');
    }
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

Required implementations

You must implement two members:
@override
PresentumSurface get surface; // Which surface to observe

@override
void onActiveItemChanged({
  required TItem? current,
  required TItem? previous,
}); // React to changes

Understanding state transitions

The onActiveItemChanged callback receives both current and previous items, enabling you to handle all state transitions:
A new item became active on an empty surface:
@override
void onActiveItemChanged({
  required CampaignItem? current,
  required CampaignItem? previous,
}) {
  if (current case final item? when previous == null) {
    // Show UI for new item
    showBanner(item);
  }
}
The active item became inactive:
@override
void onActiveItemChanged({
  required CampaignItem? current,
  required CampaignItem? previous,
}) {
  if (previous case final item? when current == null) {
    // Hide UI for deactivated item
    hideBanner();
  }
}
The active item was replaced by another:
@override
void onActiveItemChanged({
  required CampaignItem? current,
  required CampaignItem? previous,
}) {
  if (current case final newItem? when previous != null) {
    // Replace old item with new one
    updateBanner(from: previous, to: newItem);
  }
}

Production example: Update snackbar

This observer shows a snackbar when app updates are available:
class AppUpdatesPopupHost extends StatefulWidget {
  const AppUpdatesPopupHost({required this.child, super.key});

  final Widget child;

  @override
  State<AppUpdatesPopupHost> createState() => _AppUpdatesPopupHostState();
}

class _AppUpdatesPopupHostState extends State<AppUpdatesPopupHost>
    with
        PresentumActiveSurfaceItemObserverMixin<
          AppUpdatesItem,
          AppSurface,
          AppVariant,
          AppUpdatesPopupHost
        > {
  late final UpdateSnackbar _updateSnackbar;

  @override
  void initState() {
    _updateSnackbar = UpdateSnackbar();
    super.initState();
  }

  @override
  AppSurface get surface => AppSurface.updateSnackbar;

  @override
  void onActiveItemChanged({
    required AppUpdatesItem? current,
    required AppUpdatesItem? previous,
  }) {
    if (current case final _? when previous == null) {
      _updateSnackbar.show(context);
    }
    if (previous case final _? when current == null) {
      _updateSnackbar.hide();
    }
  }

  @override
  Widget build(BuildContext context) => widget.child;
}
See full source →
This observer doesn’t need popup-specific features like duplicate detection or conflict resolution - it simply shows/hides a snackbar based on state.

When NOT to use surface observers

Do not use surface observers for side effects or business logic that doesn’t involve UI rendering.If your observer’s build() method just returns widget.child with no UI changes, you’re using the wrong tool. Use Transition observers instead.
Examples of incorrect usage:
  • Triggering API calls when surface state changes
  • Starting/stopping timers based on state
  • Logging or analytics
  • BLoC/Provider integration
These are side effects and belong in transition observers, not widget-based observers.

Optional configuration

Handle initial state

Control whether to process the initial active item in initState:
@override
bool get handleInitialState => true; // default
Set to false if you only want to react to changes, not the initial state.

Access current active item

You can access the current active item at any time:
final activeItem = currentActiveItem;
if (activeItem != null) {
  // Do something with the active item
}

Access the observer

For advanced use cases, you can access the underlying observer:
final observer = this.observer; // PresentumStateObserver<TItem, S, V>
final currentState = observer.value;

When to use surface observers

Use PresentumActiveSurfaceItemObserverMixin only when you need to render UI based on surface state:

Snackbars & Banners

Show/hide snackbars, banners, toasts, or badges based on surface state

Visual Overlays

Display non-modal overlays or floating UI elements

UI Animations

Trigger widget animations tied to surface state changes

Dynamic Widgets

Render or update widgets based on active items
Key indicator: Your build() method returns actual UI or your widget manages visible elements.

Best practices

Leverage Dart 3 pattern matching to make state transitions explicit:
// ✅ Good - clear intent
if (current case final item? when previous == null) {
  // Handle activation
}

// ❌ Less clear
if (current != null && previous == null) {
  // Handle activation
}
Always cancel timers, subscriptions, or animations:
@override
void dispose() {
  _timer?.cancel();
  _animationController.dispose();
  super.dispose();
}
Consider all possible state transitions:
@override
void onActiveItemChanged({
  required Item? current,
  required Item? previous,
}) {
  // Activation: null -> Item
  if (current case final item? when previous == null) {
    handleActivation(item);
  }
  
  // Deactivation: Item -> null
  if (previous case final item? when current == null) {
    handleDeactivation(item);
  }
  
  // Replacement: Item1 -> Item2
  if (current case final newItem? when previous != null) {
    handleReplacement(from: previous, to: newItem);
  }
}
Avoid heavy computation in the callback. Use scheduleMicrotask or Future for async work:
@override
void onActiveItemChanged({
  required Item? current,
  required Item? previous,
}) {
  if (current == null) return;
  
  // ✅ Good - async work scheduled separately
  scheduleMicrotask(() => _performHeavyWork(current));
  
  // ❌ Bad - blocks the callback
  // _performHeavyWork(current);
}

Next steps