Properly Implement Screenview and App Close Events in Flutter Apps
Properly Implementing Screen View and App Close Events in Flutter In mobile app development, tracking user interactions is crucial for understanding user behavior and improving the app experience. Two fundamental events to track are screen views (when users navigate to different screens) and app close events (when users exit or minimize the app). This post will guide you through implementing these events in Flutter applications. Why Track These Events? Screen Views: Help understand user navigation patterns and identify popular or problematic screens App Close Events: Provide insights into user exit points and session durations Implementation Overview We'll cover five main components: Setting up an analytics service Creating a custom navigator observer for screen tracking Implementing app lifecycle handling for app close events Handling custom screens like bottom sheets and dialogs Preventing unnecessary screen view events Let's dive in! 1. Setting Up the Analytics Service First, create a service class to handle analytics events. This example uses Firebase Analytics, but the concepts apply to any analytics provider. import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/material.dart'; class AnalyticsService { AnalyticsService._(); // Private constructor for singleton pattern static final instance = AnalyticsService._(); final _analytics = FirebaseAnalytics.instance; // Log custom events void logEvent(String name, Map parameters) { _analytics .logEvent(name: name, parameters: parameters) .then((value) => debugPrint('Event logged: $name')) .catchError((dynamic e) => debugPrint('Error logging event: $e')); } // Log screen view events void logScreenView(String screenName) { _analytics.logScreenView( screenName: screenName, ); } } 2. Creating a Custom Navigator Observer for Screen Tracking Flutter's NavigatorObserver allows you to listen for navigation events. By extending it, you can automatically track screen views: import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/material.dart'; /// A custom implementation of NavigatorObserver that handles screen view tracking class CustomAnalyticsObserver extends NavigatorObserver { CustomAnalyticsObserver({ required this.analytics, this.nameExtractor, this.routeFilter, }); final FirebaseAnalytics analytics; final RouteFilter? routeFilter; final NameExtractor? nameExtractor; // Keep track of the current tab name to avoid duplicate events String? _currentTabName; /// Log a screen_view event when a new route is pushed @override void didPush(Route route, Route? previousRoute) { super.didPush(route, previousRoute); _sendScreenView(route); } /// Log a screen_view event when a route is replaced @override void didReplace({Route? newRoute, Route? oldRoute}) { super.didReplace(newRoute: newRoute, oldRoute: oldRoute); if (newRoute != null) { _sendScreenView(newRoute); } } /// Log a screen_view event when returning to a previous route @override void didPop(Route route, Route? previousRoute) { super.didPop(route, previousRoute); if (previousRoute != null) { _sendScreenView(previousRoute); } } /// Handles the screen view tracking void _sendScreenView(Route route) { if (routeFilter != null && !routeFilter!(route)) return; final screenName = nameExtractor != null ? nameExtractor!(route) : route.settings.name; if (screenName != null) { // Check if screen view events should be paused if (pauseScreenView) { pauseScreenView = false; return; } analytics.logScreenView(screenName: screenName); } } /// Track bottom navigation tab changes void trackTabChange(String tabName) { // Avoid logging duplicate events for the same tab if (_currentTabName == tabName) return; _currentTabName = tabName; analytics.logScreenView(screenName: tabName); } } /// Signature for a function that extracts a screen name from a Route typedef NameExtractor = String? Function(Route route); /// Signature for a function that determines whether a Route should be tracked typedef RouteFilter = bool Function(Route route); Integration with Navigation Packages If you're using a navigation package like GoRouter, you can extend the functionality: /// Extension for GoRouter to use our custom analytics observer extension GoRouterAnalyticsExtension on GoRouter { static CustomAnalyticsObserver createObserver(FirebaseAnalytics analytics) { return CustomAnalyticsObserver( analytics: analytics, nameExtractor: (route) { // Extract meaningful names from GoRouter routes if (route.settings.name != null) { return route.settings.name; } // For GoRouter routes, try to

Properly Implementing Screen View and App Close Events in Flutter
In mobile app development, tracking user interactions is crucial for understanding user behavior and improving the app experience. Two fundamental events to track are screen views (when users navigate to different screens) and app close events (when users exit or minimize the app). This post will guide you through implementing these events in Flutter applications.
Why Track These Events?
- Screen Views: Help understand user navigation patterns and identify popular or problematic screens
- App Close Events: Provide insights into user exit points and session durations
Implementation Overview
We'll cover five main components:
- Setting up an analytics service
- Creating a custom navigator observer for screen tracking
- Implementing app lifecycle handling for app close events
- Handling custom screens like bottom sheets and dialogs
- Preventing unnecessary screen view events
Let's dive in!
1. Setting Up the Analytics Service
First, create a service class to handle analytics events. This example uses Firebase Analytics, but the concepts apply to any analytics provider.
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart';
class AnalyticsService {
AnalyticsService._(); // Private constructor for singleton pattern
static final instance = AnalyticsService._();
final _analytics = FirebaseAnalytics.instance;
// Log custom events
void logEvent(String name, Map<String, Object> parameters) {
_analytics
.logEvent(name: name, parameters: parameters)
.then((value) => debugPrint('Event logged: $name'))
.catchError((dynamic e) => debugPrint('Error logging event: $e'));
}
// Log screen view events
void logScreenView(String screenName) {
_analytics.logScreenView(
screenName: screenName,
);
}
}
2. Creating a Custom Navigator Observer for Screen Tracking
Flutter's NavigatorObserver
allows you to listen for navigation events. By extending it, you can automatically track screen views:
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart';
/// A custom implementation of NavigatorObserver that handles screen view tracking
class CustomAnalyticsObserver extends NavigatorObserver {
CustomAnalyticsObserver({
required this.analytics,
this.nameExtractor,
this.routeFilter,
});
final FirebaseAnalytics analytics;
final RouteFilter? routeFilter;
final NameExtractor? nameExtractor;
// Keep track of the current tab name to avoid duplicate events
String? _currentTabName;
/// Log a screen_view event when a new route is pushed
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPush(route, previousRoute);
_sendScreenView(route);
}
/// Log a screen_view event when a route is replaced
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
if (newRoute != null) {
_sendScreenView(newRoute);
}
}
/// Log a screen_view event when returning to a previous route
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPop(route, previousRoute);
if (previousRoute != null) {
_sendScreenView(previousRoute);
}
}
/// Handles the screen view tracking
void _sendScreenView(Route<dynamic> route) {
if (routeFilter != null && !routeFilter!(route)) return;
final screenName =
nameExtractor != null ? nameExtractor!(route) : route.settings.name;
if (screenName != null) {
// Check if screen view events should be paused
if (pauseScreenView) {
pauseScreenView = false;
return;
}
analytics.logScreenView(screenName: screenName);
}
}
/// Track bottom navigation tab changes
void trackTabChange(String tabName) {
// Avoid logging duplicate events for the same tab
if (_currentTabName == tabName) return;
_currentTabName = tabName;
analytics.logScreenView(screenName: tabName);
}
}
/// Signature for a function that extracts a screen name from a Route
typedef NameExtractor = String? Function(Route<dynamic> route);
/// Signature for a function that determines whether a Route should be tracked
typedef RouteFilter = bool Function(Route<dynamic> route);
Integration with Navigation Packages
If you're using a navigation package like GoRouter, you can extend the functionality:
/// Extension for GoRouter to use our custom analytics observer
extension GoRouterAnalyticsExtension on GoRouter {
static CustomAnalyticsObserver createObserver(FirebaseAnalytics analytics) {
return CustomAnalyticsObserver(
analytics: analytics,
nameExtractor: (route) {
// Extract meaningful names from GoRouter routes
if (route.settings.name != null) {
return route.settings.name;
}
// For GoRouter routes, try to extract the location
final routeMatch = route.settings.arguments;
if (routeMatch is Map && routeMatch.containsKey('location')) {
return routeMatch['location'] as String;
}
return null;
},
);
}
}
3. Implementing App Lifecycle Handling for App Close Events
Flutter provides the AppLifecycleState
enum to track the app's lifecycle. You can use this to detect when the app is being closed or minimized:
import 'dart:developer';
import 'package:flutter/material.dart';
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
// State variables and services for your app
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
// Get the current screen name from local storage
Future<String?> getCurrentScreen() async {
// Retrieve the current screen from local storage
return await LocalStoreService.instance.getUserPref('current_screen');
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
// App is minimized or closed
if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.paused) {
final screenName = await getCurrentScreen();
log('App lifecycle changed: $screenName, state: $state');
if (screenName != null) {
AnalyticsService.instance.logEvent(
'app_close',
{
'screen_name': screenName,
'type': 'user_minimised',
},
);
}
}
// App is resumed from background
if (state == AppLifecycleState.resumed) {
final screenName = await getCurrentScreen();
log('App resumed: $screenName');
if (screenName != null) {
AnalyticsService.instance.logScreenView(screenName);
}
}
}
@override
Widget build(BuildContext context) {
// Your app widget tree
return MaterialApp(
// ...
);
}
}
4. Handling Custom Screens Like Bottom Sheets and Dialogs
Bottom sheets, dialogs, and other modal interfaces aren't automatically tracked by the navigator observer since they don't always create new routes. A robust approach is to store the current screen name in local storage, which helps with proper tracking when the app is closed:
// When showing a bottom sheet
GestureDetector(
onTap: () {
// Save the current screen name before showing the bottom sheet
LocalStoreService.instance.saveUserPref(
'current_screen',
ScreenNames.filterScreen,
);
// Show the bottom sheet
showModalBottomSheet(
isScrollControlled: true,
useSafeArea: true,
context: context,
useRootNavigator: true,
routeSettings: const RouteSettings(
name: FilterBottomSheet.routeName,
),
builder: (_) => FilterBottomSheet(
searchId: searchId,
),
).then((_) {
// When bottom sheet is closed, restore the previous screen
LocalStoreService.instance.saveUserPref(
'current_screen',
ScreenNames.resultScreen,
);
});
},
child: YourButtonWidget(),
)
This approach ensures that:
- The app knows which screen the user is viewing, even for modal interfaces
- App close events can accurately report the current screen
- When the modal is dismissed, the previous screen is restored for tracking
You'll also need to implement a method to retrieve the current screen from storage:
Future<String?> getCurrentScreen() async {
return await LocalStoreService.instance.getUserPref('current_screen');
}
7. Putting It All Together
Here's how to integrate all components in your main.dart file:
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Firebase
runApp(MyApp());
}
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
final analytics = FirebaseAnalytics.instance;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Future<String?> getCurrentScreen() async {
// Your implementation to get current screen
return 'current_screen';
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.paused) {
final screenName = await getCurrentScreen();
if (isExitClick) {
isExitClick = false;
return;
}
if (screenName != null) {
AnalyticsService.instance.logEvent(
'app_close',
{
'screen_name': screenName,
'type': 'user_minimised',
},
);
}
}
if (state == AppLifecycleState.resumed) {
final screenName = await getCurrentScreen();
if (isExitClick) {
isExitClick = false;
}
if (screenName != null) {
AnalyticsService.instance.logScreenView(screenName);
}
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorObservers: [
CustomAnalyticsObserver(analytics: analytics),
],
// Your app configuration
);
}
}
5. Preventing Unnecessary Screen View Events
In complex navigation scenarios, you might encounter situations where multiple screen view events are triggered unnecessarily. For example, when using nested navigators or when replacing routes in quick succession. To prevent this, you can implement a flag to temporarily pause screen view tracking:
// Define a global flag to control screen view tracking
bool pauseScreenView = false;
// In your custom analytics observer
void _sendScreenView(Route<dynamic> route) {
// Extract screen name...
if (screenName != null) {
// Check if screen view events should be paused
if (pauseScreenView) {
pauseScreenView = false;
return;
}
analytics.logScreenView(screenName: screenName);
}
}
You can then set this flag before navigating to prevent the next screen view event from being logged:
void navigateWithoutLogging() {
// Set the flag to pause the next screen view event
pauseScreenView = true;
// Navigate to the next screen
Navigator.of(context).pushNamed('/next-screen');
}
This is particularly useful in scenarios like:
- Redirects: When you automatically redirect from one screen to another
- Deep linking: When opening the app via a deep link that triggers multiple navigation events
- Authentication flows: When a user needs to be redirected based on auth state
6. Best Practices
- Define Constants: Create a constants file for event names and screen names to ensure consistency:
class FirebaseEventsNames {
static const String appClose = 'app_close';
static const String searchClick = 'search_button_clicked';
static const String resultFound = 'result_found';
}
class ScreenNames {
static const String homeScreen = 'home_screen';
static const String detailScreen = 'detail_screen';
static const String filterScreen = 'filter_screen';
// Add all your screens here
}
-
Debug Logging: Use
log
statements to debug your analytics implementation:
import 'dart:developer';
log('Screen view: $screenName');
Avoid Duplicate Events: Implement logic to prevent duplicate screen view events, especially with bottom navigation tabs.
Handle Deep Links: Make sure your analytics tracking works correctly with deep links.
Test Thoroughly: Verify that events are being sent correctly using the Firebase Analytics DebugView or your analytics provider's testing tools.
Conclusion
Properly implementing screen view and app close events in Flutter provides valuable insights into user behavior. By following this guide, you can set up a robust analytics system that tracks these important events.
Remember that the implementation may vary slightly depending on your navigation solution (Navigator 1.0, Navigator 2.0, GoRouter, etc.), but the core principles remain the same.
Happy coding! <3 from Sajith Lal