Tracking from widgets¶
herald_widgets is an optional package for tracking from the widget tree: screen views each time a
screen becomes visible, impressions when something is really on screen, and taps in small widgets
that have no bloc. It's the Flutter counterpart of the Android SDK's
Compose helpers and the iOS SDK's SwiftUI helpers.
Do you need it?¶
Not to use Herald. A screen, bloc or view model can track its own events, screen views included,
through EventTrackerService, as on every other page of this site:
// Plain Herald, no extra package: the screen tracks its own screen view, once each time it opens.
final class const SettingsPage(final EventTrackerService analytics, {super.key})
extends StatefulWidget {
@override
State<SettingsPage> createState() => _SettingsPageState();
}
final class _SettingsPageState extends State<SettingsPage> {
@override
void initState() {
super.initState();
widget.analytics.track(const SettingsScreenViewed()).ignore(); // Herald never fails a call
}
@override
Widget build(BuildContext context) => const Scaffold();
}
That counts a screen once each time it opens. Use herald_widgets when tracking from the widget
tree is the simpler fit, such as counting a screen each time the user comes back to it.
Set it up once¶
HeraldScope gives widgets below it the tracker, and HeraldRouteObserver tells them when their
screen is shown or hidden:
final class ShopApp(final Herald herald) extends StatelessWidget {
// One per Navigator: it tells screens when they are shown or hidden.
final routes = HeraldRouteObserver();
@override
Widget build(BuildContext context) => HeraldScope(
analytics: herald,
routes: routes,
child: MaterialApp(
navigatorObservers: [routes], // go_router: GoRouter(observers: [routes])
home: const ProductPage('day_pass'),
),
);
}
The widgets¶
They follow the same rule as the Compose helpers: track when a screen is shown or hidden, or when something is really on screen, never when a widget is built. Flutter rebuilds widgets all the time, and the user did nothing to cause it.
| Widget | Tracks | Answers |
|---|---|---|
TrackScreenView(event: …) |
each time its screen becomes visible: first show, coming back to it, the app returning to view | how often is this screen looked at? |
TrackOnScreen(event: …, on: …) |
each time its screen is shown or hidden |
the same, for any event: hidden for "left the screen" |
HeraldScope.of(context) |
when you call track on it |
a tap in a small widget with no bloc |
TrackImpression(event: …, threshold: …, minVisibleDuration: …) |
once per appearance, when that share of the child has been visible that long | was this actually seen? |
A screen is a page, a PageRoute. Dialogs, bottom sheets and menus over it don't hide it, and the
app briefly losing focus, as under Android's notification shade or iOS's Control Centre, doesn't
either. On iOS the notification centre sends the app to the background, so it hides the screen.
Screen views¶
final class const ProductPage(final String productId, {super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => TrackScreenView(
event: ProductScreenViewed(productId),
child: Scaffold(appBar: AppBar(title: Text(productId))),
);
}
To track when the user leaves a screen:
TrackOnScreen(
event: CheckoutLeft(itemsInCart),
on: ScreenMoment.hidden, // pushed over, closed, or the app went out of view
child: const Scaffold(),
);
Taps¶
ElevatedButton(
onPressed: () => HeraldScope.of(context).track(PromoBannerTapped(promoId)),
child: const Text('See the offer'),
);
Events that come from your app's logic belong in its bloc or view model. This is for small widgets that have none.
Impressions¶
ListView(
children: [
for (final offerId in offerIds)
TrackImpression(
event: OfferShown(offerId),
threshold: 0.5, // half of it on screen
minVisibleDuration: const Duration(seconds: 1), // for a second, not a fast scroll
child: ListTile(title: Text(offerId)),
),
],
);
- Once per appearance. An item that scrolls out of the list and back in counts again, which is what an impression is. "Each offer once per session" is your app's rule, so it belongs in your code.
- Give events value equality. A new, unequal event starts the count again, so an event without
==restarts it on every rebuild.
Limits¶
- Tabs aren't screens. Switching tabs doesn't push a page, so track it from the code that switches.
- A nested
Navigator, such as go_router's shell route, needs aHeraldRouteObserverof its own, given to aHeraldScopearound it.
Tests and previews¶
Wrap the widget in a HeraldScope with a FakeAnalyticsProvider from herald_testing, as in
Testing, and add a HeraldRouteObserver for screens. A widget preview
can do the same through @Preview(wrapper: …).