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

May 5, 2025 - 08:50
 0
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:

  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);

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:

  1. The app knows which screen the user is viewing, even for modal interfaces
  2. App close events can accurately report the current screen
  3. 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:

  1. Redirects: When you automatically redirect from one screen to another
  2. Deep linking: When opening the app via a deep link that triggers multiple navigation events
  3. Authentication flows: When a user needs to be redirected based on auth state

6. Best Practices

  1. 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
}
  1. Debug Logging: Use log statements to debug your analytics implementation:
import 'dart:developer';

log('Screen view: $screenName');
  1. Avoid Duplicate Events: Implement logic to prevent duplicate screen view events, especially with bottom navigation tabs.

  2. Handle Deep Links: Make sure your analytics tracking works correctly with deep links.

  3. 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