Properly Implement Screenview and App Close Events in Flutter Apps
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?
Implementation Overview
We'll cover five main components:
1. Setting up an analytics service
2. Creating a custom navigator observer for screen tracking
3. Implementing app lifecycle handling for app close events
4. Handling custom screens like bottom sheets and dialogs
5. 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);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 {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Future<String?> getCurrentScreen() async {
String? screenName;
//implement fetching current screen name from local storage
return screenName;
}
@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) {
return MaterialApp(
// Your app configuration
);
}
}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:
GestureDetector(
onTap: () {
// Save the current screen name before showing the bottom sheet
LocalStoreService.instance.saveUserPref(
'current_screen',
ScreenNames.filterScreen,
);
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(),
)5. Preventing Unnecessary Screen View Events
In complex navigation scenarios, you might encounter situations where multiple screen view events are triggered unnecessarily. To prevent this, 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) {
if (screenName != null) {
// Check if screen view events should be paused
if (pauseScreenView) {
pauseScreenView = false;
return;
}
analytics.logScreenView(screenName: screenName);
}
}Best Practices
1. Define Constants: Create a constants file for event names and screen names
2. Debug Logging: Use log statements to debug your analytics implementation
3. Avoid Duplicate Events: Implement logic to prevent duplicate screen view events
4. Handle Deep Links: Make sure your analytics tracking works correctly with deep links
5. Test Thoroughly: Verify events using Firebase Analytics DebugView
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! ❤️ from Sajith Lal
Senior Flutter Developer with 5+ years of experience building high-performance cross-platform applications. Specialized in Flutter, Dart, Firebase & clean architecture.