diff --git a/examples/api/lib/widgets/app_lifecycle_listener/app_lifecycle_listener.0.dart b/examples/api/lib/widgets/app_lifecycle_listener/app_lifecycle_listener.0.dart new file mode 100644 index 0000000000..09ad44d79f --- /dev/null +++ b/examples/api/lib/widgets/app_lifecycle_listener/app_lifecycle_listener.0.dart @@ -0,0 +1,100 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +/// Flutter code sample for [AppLifecycleListener]. + +void main() { + runApp(const AppLifecycleListenerExample()); +} + +class AppLifecycleListenerExample extends StatelessWidget { + const AppLifecycleListenerExample({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold(body: AppLifecycleDisplay()), + ); + } +} + +class AppLifecycleDisplay extends StatefulWidget { + const AppLifecycleDisplay({super.key}); + + @override + State createState() => _AppLifecycleDisplayState(); +} + +class _AppLifecycleDisplayState extends State { + late final AppLifecycleListener _listener; + final ScrollController _scrollController = ScrollController(); + final List _states = []; + late AppLifecycleState? _state; + + @override + void initState() { + super.initState(); + _state = SchedulerBinding.instance.lifecycleState; + _listener = AppLifecycleListener( + onShow: () => _handleTransition('show'), + onResume: () => _handleTransition('resume'), + onHide: () => _handleTransition('hide'), + onInactive: () => _handleTransition('inactive'), + onPause: () => _handleTransition('pause'), + onDetach: () => _handleTransition('detach'), + onRestart: () => _handleTransition('restart'), + // This fires for each state change. Callbacks above fire only for + // specific state transitions. + onStateChange: _handleStateChange, + ); + if (_state != null) { + _states.add(_state!.name); + } + } + + @override + void dispose() { + _listener.dispose(); + super.dispose(); + } + + void _handleTransition(String name) { + setState(() { + _states.add(name); + }); + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + void _handleStateChange(AppLifecycleState state) { + setState(() { + _state = state; + }); + } + + @override + Widget build(BuildContext context) { + return Center( + child: SizedBox( + width: 300, + child: SingleChildScrollView( + controller: _scrollController, + child: Column( + children: [ + Text('Current State: ${_state ?? 'Not initialized yet'}'), + const SizedBox(height: 30), + Text('State History:\n ${_states.join('\n ')}'), + ], + ), + ), + ), + ); + } +} diff --git a/examples/api/lib/widgets/app_lifecycle_listener/app_lifecycle_listener.1.dart b/examples/api/lib/widgets/app_lifecycle_listener/app_lifecycle_listener.1.dart new file mode 100644 index 0000000000..3315f81732 --- /dev/null +++ b/examples/api/lib/widgets/app_lifecycle_listener/app_lifecycle_listener.1.dart @@ -0,0 +1,109 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Flutter code sample for [AppLifecycleListener]. + +void main() { + runApp(const AppLifecycleListenerExample()); +} + +class AppLifecycleListenerExample extends StatelessWidget { + const AppLifecycleListenerExample({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold(body: ApplicationExitControl()), + ); + } +} + +class ApplicationExitControl extends StatefulWidget { + const ApplicationExitControl({super.key}); + + @override + State createState() => _ApplicationExitControlState(); +} + +class _ApplicationExitControlState extends State { + late final AppLifecycleListener _listener; + bool _shouldExit = false; + String _lastExitResponse = 'No exit requested yet'; + + @override + void initState() { + super.initState(); + _listener = AppLifecycleListener( + onExitRequested: _handleExitRequest, + ); + } + + @override + void dispose() { + _listener.dispose(); + super.dispose(); + } + + Future _quit() async { + final AppExitType exitType = _shouldExit ? AppExitType.required : AppExitType.cancelable; + await ServicesBinding.instance.exitApplication(exitType); + } + + Future _handleExitRequest() async { + final AppExitResponse response = _shouldExit ? AppExitResponse.exit : AppExitResponse.cancel; + setState(() { + _lastExitResponse = 'App responded ${response.name} to exit request'; + }); + return response; + } + + void _radioChanged(bool? value) { + value ??= true; + if (_shouldExit == value) { + return; + } + setState(() { + _shouldExit = value!; + }); + } + + @override + Widget build(BuildContext context) { + return Center( + child: SizedBox( + width: 300, + child: IntrinsicHeight( + child: Column( + children: [ + RadioListTile( + title: const Text('Do Not Allow Exit'), + groupValue: _shouldExit, + value: false, + onChanged: _radioChanged, + ), + RadioListTile( + title: const Text('Allow Exit'), + groupValue: _shouldExit, + value: true, + onChanged: _radioChanged, + ), + const SizedBox(height: 30), + ElevatedButton( + onPressed: _quit, + child: const Text('Quit'), + ), + const SizedBox(height: 30), + Text('Exit Request: $_lastExitResponse'), + ], + ), + ), + ), + ); + } +} diff --git a/examples/api/test/widgets/app_lifecycle_listener/app_lifecycle_listener.0_test.dart b/examples/api/test/widgets/app_lifecycle_listener/app_lifecycle_listener.0_test.dart new file mode 100644 index 0000000000..6c37d9e10a --- /dev/null +++ b/examples/api/test/widgets/app_lifecycle_listener/app_lifecycle_listener.0_test.dart @@ -0,0 +1,17 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_api_samples/widgets/app_lifecycle_listener/app_lifecycle_listener.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('AppLifecycleListener example', (WidgetTester tester) async { + await tester.pumpWidget( + const example.AppLifecycleListenerExample(), + ); + + expect(find.textContaining('Current State:'), findsOneWidget); + expect(find.textContaining('State History:'), findsOneWidget); + }); +} diff --git a/examples/api/test/widgets/app_lifecycle_listener/app_lifecycle_listener.1_test.dart b/examples/api/test/widgets/app_lifecycle_listener/app_lifecycle_listener.1_test.dart new file mode 100644 index 0000000000..3218f7348b --- /dev/null +++ b/examples/api/test/widgets/app_lifecycle_listener/app_lifecycle_listener.1_test.dart @@ -0,0 +1,27 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_api_samples/widgets/app_lifecycle_listener/app_lifecycle_listener.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('AppLifecycleListener example', (WidgetTester tester) async { + await tester.pumpWidget( + const example.AppLifecycleListenerExample(), + ); + + expect(find.text('Do Not Allow Exit'), findsOneWidget); + expect(find.text('Allow Exit'), findsOneWidget); + expect(find.text('Quit'), findsOneWidget); + expect(find.textContaining('Exit Request:'), findsOneWidget); + await tester.tap(find.text('Quit')); + await tester.pump(); + // Responding to the the quit request happens in a Future that we don't have + // visibility for, so to avoid a flaky test with a delay, we just check to + // see if the request string prefix is still there, rather than the request + // response string. Testing it wasn't worth exposing a Completer in the + // example code. + expect(find.textContaining('Exit Request:'), findsOneWidget); + }); +} diff --git a/examples/api/test/widgets/binding/widget_binding_observer.0_test.dart b/examples/api/test/widgets/binding/widget_binding_observer.0_test.dart index 9637c830ff..6da54447e5 100644 --- a/examples/api/test/widgets/binding/widget_binding_observer.0_test.dart +++ b/examples/api/test/widgets/binding/widget_binding_observer.0_test.dart @@ -33,14 +33,14 @@ void main() { await setAppLifeCycleState(AppLifecycleState.paused); await tester.pumpAndSettle(); - await setAppLifeCycleState(AppLifecycleState.resumed); - await tester.pumpAndSettle(); - expect(find.text('state is: AppLifecycleState.paused'), findsOneWidget); + // Can't look for paused text here because rendering is paused. - await setAppLifeCycleState(AppLifecycleState.detached); + await setAppLifeCycleState(AppLifecycleState.inactive); await tester.pumpAndSettle(); + expect(find.text('state is: AppLifecycleState.inactive'), findsNWidgets(2)); + await setAppLifeCycleState(AppLifecycleState.resumed); await tester.pumpAndSettle(); - expect(find.text('state is: AppLifecycleState.detached'), findsOneWidget); + expect(find.text('state is: AppLifecycleState.resumed'), findsNWidgets(2)); }); } diff --git a/packages/flutter/lib/src/scheduler/binding.dart b/packages/flutter/lib/src/scheduler/binding.dart index e4d82668cf..dc690ab518 100644 --- a/packages/flutter/lib/src/scheduler/binding.dart +++ b/packages/flutter/lib/src/scheduler/binding.dart @@ -371,8 +371,9 @@ mixin SchedulerBinding on BindingBase { /// This is set by [handleAppLifecycleStateChanged] when the /// [SystemChannels.lifecycle] notification is dispatched. /// - /// The preferred way to watch for changes to this value is using - /// [WidgetsBindingObserver.didChangeAppLifecycleState]. + /// The preferred ways to watch for changes to this value are using + /// [WidgetsBindingObserver.didChangeAppLifecycleState], or through an + /// [AppLifecycleListener] object. AppLifecycleState? get lifecycleState => _lifecycleState; AppLifecycleState? _lifecycleState; @@ -392,19 +393,18 @@ mixin SchedulerBinding on BindingBase { @protected @mustCallSuper void handleAppLifecycleStateChanged(AppLifecycleState state) { + if (lifecycleState == state) { + return; + } _lifecycleState = state; switch (state) { case AppLifecycleState.resumed: case AppLifecycleState.inactive: _setFramesEnabledState(true); + case AppLifecycleState.hidden: case AppLifecycleState.paused: case AppLifecycleState.detached: _setFramesEnabledState(false); - // ignore: no_default_cases - default: - // TODO(gspencergoog): Remove this and replace with real cases once - // engine change rolls into framework. - break; } } diff --git a/packages/flutter/lib/src/services/binding.dart b/packages/flutter/lib/src/services/binding.dart index 490fd5057a..9969071815 100644 --- a/packages/flutter/lib/src/services/binding.dart +++ b/packages/flutter/lib/src/services/binding.dart @@ -243,28 +243,104 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { /// /// Once the [lifecycleState] is populated through any means (including this /// method), this method will do nothing. This is because the - /// [dart:ui.PlatformDispatcher.initialLifecycleState] may already be - /// stale and it no longer makes sense to use the initial state at dart vm - /// startup as the current state anymore. + /// [dart:ui.PlatformDispatcher.initialLifecycleState] may already be stale + /// and it no longer makes sense to use the initial state at dart vm startup + /// as the current state anymore. /// /// The latest state should be obtained by subscribing to /// [WidgetsBindingObserver.didChangeAppLifecycleState]. @protected void readInitialLifecycleStateFromNativeWindow() { - if (lifecycleState != null) { + if (lifecycleState != null || platformDispatcher.initialLifecycleState.isEmpty) { return; } - final AppLifecycleState? state = _parseAppLifecycleMessage(platformDispatcher.initialLifecycleState); - if (state != null) { - handleAppLifecycleStateChanged(state); - } + _handleLifecycleMessage(platformDispatcher.initialLifecycleState); } Future _handleLifecycleMessage(String? message) async { - handleAppLifecycleStateChanged(_parseAppLifecycleMessage(message!)!); + final AppLifecycleState? state = _parseAppLifecycleMessage(message!); + final List generated = _generateStateTransitions(lifecycleState, state!); + generated.forEach(handleAppLifecycleStateChanged); return null; } + List _generateStateTransitions(AppLifecycleState? previousState, AppLifecycleState state) { + if (previousState == state) { + return const []; + } + if (previousState == AppLifecycleState.paused && state == AppLifecycleState.detached) { + // Handle the wrap-around from paused to detached + return const [ + AppLifecycleState.detached, + ]; + } + final List stateChanges = []; + if (previousState == null) { + // If there was no previous state, just jump directly to the new state. + stateChanges.add(state); + } else { + final int previousStateIndex = AppLifecycleState.values.indexOf(previousState); + final int stateIndex = AppLifecycleState.values.indexOf(state); + assert(previousStateIndex != -1, 'State $previousState missing in stateOrder array'); + assert(stateIndex != -1, 'State $state missing in stateOrder array'); + if (previousStateIndex > stateIndex) { + for (int i = stateIndex; i < previousStateIndex; ++i) { + stateChanges.insert(0, AppLifecycleState.values[i]); + } + } else { + for (int i = previousStateIndex + 1; i <= stateIndex; ++i) { + stateChanges.add(AppLifecycleState.values[i]); + } + } + } + assert((){ + AppLifecycleState? starting = previousState; + for (final AppLifecycleState ending in stateChanges) { + if (!_debugVerifyLifecycleChange(starting, ending)) { + return false; + } + starting = ending; + } + return true; + }(), 'Invalid lifecycle state transition generated from $previousState to $state (generated $stateChanges)'); + return stateChanges; + } + + static bool _debugVerifyLifecycleChange(AppLifecycleState? starting, AppLifecycleState ending) { + if (starting == null) { + // Any transition from null is fine, since it is initializing the state. + return true; + } + if (starting == ending) { + // Any transition to itself shouldn't happen. + return false; + } + switch (starting) { + case AppLifecycleState.detached: + if (ending == AppLifecycleState.resumed || ending == AppLifecycleState.paused) { + return true; + } + case AppLifecycleState.resumed: + // Can't go from resumed to detached directly (must go through paused). + if (ending == AppLifecycleState.inactive) { + return true; + } + case AppLifecycleState.inactive: + if (ending == AppLifecycleState.resumed || ending == AppLifecycleState.hidden) { + return true; + } + case AppLifecycleState.hidden: + if (ending == AppLifecycleState.inactive || ending == AppLifecycleState.paused) { + return true; + } + case AppLifecycleState.paused: + if (ending == AppLifecycleState.hidden || ending == AppLifecycleState.detached) { + return true; + } + } + return false; + } + Future _handlePlatformMessage(MethodCall methodCall) async { final String method = methodCall.method; assert(method == 'SystemChrome.systemUIChange' || method == 'System.requestAppExit'); @@ -359,7 +435,6 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { /// /// * [WidgetsBindingObserver.didRequestAppExit] for a handler you can /// override on a [WidgetsBindingObserver] to receive exit requests. - @mustCallSuper Future exitApplication(ui.AppExitType exitType, [int exitCode = 0]) async { final Map? result = await SystemChannels.platform.invokeMethod>( 'System.exitApplication', diff --git a/packages/flutter/lib/src/widgets/app_lifecycle_listener.dart b/packages/flutter/lib/src/widgets/app_lifecycle_listener.dart new file mode 100644 index 0000000000..54b918d62b --- /dev/null +++ b/packages/flutter/lib/src/widgets/app_lifecycle_listener.dart @@ -0,0 +1,262 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; +import 'package:flutter/foundation.dart'; + +import 'binding.dart'; + +/// A callback type that is used by [AppLifecycleListener.onExitRequested] to +/// ask the application if it wants to cancel application termination or not. +typedef AppExitRequestCallback = Future Function(); + +/// A listener that can be used to listen to changes in the application +/// lifecycle. +/// +/// To listen for requests for the application to exit, and to decide whether or +/// not the application should exit when requested, create an +/// [AppLifecycleListener] and set the [onExitRequested] callback. +/// +/// To listen for changes in the application lifecycle state, define an +/// [onStateChange] callback. See the [AppLifecycleState] enum for details on +/// the various states. +/// +/// The [onStateChange] callback is called for each state change, and the +/// individual state transitions ([onResume], [onInactive], etc.) are also +/// called if the state transition they represent occurs. +/// +/// State changes will occur in accordance with the state machine described by +/// this diagram: +/// +/// ![Diagram of the application lifecycle defined by the AppLifecycleState enum]( +/// https://flutter.github.io/assets-for-api-docs/assets/dart-ui/app_lifecycle.png) +/// +/// The initial state of the state machine is the [AppLifecycleState.detached] +/// state, and the arrows describe valid state transitions. Transitions in blue +/// are transitions that only happen on iOS and Android. +/// +/// {@tool dartpad} +/// This example shows how an application can listen to changes in the +/// application state. +/// +/// ** See code in examples/api/lib/widgets/app_lifecycle_listener/app_lifecycle_listener.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how an application can optionally decide to abort a +/// request for exiting instead of obeying the request. +/// +/// ** See code in examples/api/lib/widgets/app_lifecycle_listener/app_lifecycle_listener.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ServicesBinding.exitApplication] for a function to call that will request +/// that the application exits. +/// * [WidgetsBindingObserver.didRequestAppExit] for the handler which this +/// class uses to receive exit requests. +/// * [WidgetsBindingObserver.didChangeAppLifecycleState] for the handler which +/// this class uses to receive lifecycle state changes. +class AppLifecycleListener with WidgetsBindingObserver, Diagnosticable { + /// Creates an [AppLifecycleListener]. + AppLifecycleListener({ + WidgetsBinding? binding, + this.onResume, + this.onInactive, + this.onHide, + this.onShow, + this.onPause, + this.onRestart, + this.onDetach, + this.onExitRequested, + this.onStateChange, + }) : binding = binding ?? WidgetsBinding.instance, + _lifecycleState = (binding ?? WidgetsBinding.instance).lifecycleState { + this.binding.addObserver(this); + } + + AppLifecycleState? _lifecycleState; + + /// The [WidgetsBinding] to listen to for application lifecycle events. + /// + /// Typically, this is set to [WidgetsBinding.instance], but may be + /// substituted for testing or other specialized bindings. + /// + /// Defaults to [WidgetsBinding.instance]. + final WidgetsBinding binding; + + /// Called anytime the state changes, passing the new state. + final ValueChanged? onStateChange; + + /// A callback that is called when the application loses input focus. + /// + /// On mobile platforms, this can be during a phone call or when a system + /// dialog is visible. + /// + /// On desktop platforms, this is when all views in an application have lost + /// input focus but at least one view of the application is still visible. + /// + /// On the web, this is when the window (or tab) has lost input focus. + final VoidCallback? onInactive; + + /// A callback that is called when a view in the application gains input + /// focus. + /// + /// A call to this callback indicates that the application is entering a state + /// where it is visible, active, and accepting user input. + final VoidCallback? onResume; + + /// A callback that is called when the application is hidden. + /// + /// On mobile platforms, this is usually just before the application is + /// replaced by another application in the foreground. + /// + /// On desktop platforms, this is just before the application is hidden by + /// being minimized or otherwise hiding all views of the application. + /// + /// On the web, this is just before a window (or tab) is hidden. + final VoidCallback? onHide; + + /// A callback that is called when the application is shown. + /// + /// On mobile platforms, this is usually just before the application replaces + /// another application in the foreground. + /// + /// On desktop platforms, this is just before the application is shown after + /// being minimized or otherwise made to show at least one view of the + /// application. + /// + /// On the web, this is just before a window (or tab) is shown. + final VoidCallback? onShow; + + /// A callback that is called when the application is paused. + /// + /// On mobile platforms, this happens right before the application is replaced + /// by another application. + /// + /// On desktop platforms and the web, this function is not called. + final VoidCallback? onPause; + + /// A callback that is called when the application is resumed after being + /// paused. + /// + /// On mobile platforms, this happens just before this application takes over + /// as the active application. + /// + /// On desktop platforms and the web, this function is not called. + final VoidCallback? onRestart; + + /// A callback used to ask the application if it will allow exiting the + /// application for cases where the exit is cancelable. + /// + /// Exiting the application isn't always cancelable, but when it is, this + /// function will be called before exit occurs. + /// + /// Responding [AppExitResponse.exit] will continue termination, and + /// responding [AppExitResponse.cancel] will cancel it. If termination is not + /// canceled, the application will immediately exit. + final AppExitRequestCallback? onExitRequested; + + /// A callback that is called when an application has exited, and detached all + /// host views from the engine. + /// + /// This callback is only called on iOS and Android. + final VoidCallback? onDetach; + + bool _debugDisposed = false; + + /// Call when the listener is no longer in use. + /// + /// Do not use the object after calling [dispose]. + /// + /// Subclasses must call this method in their overridden [dispose], if any. + @mustCallSuper + void dispose() { + assert(_debugAssertNotDisposed()); + binding.removeObserver(this); + assert(() { + _debugDisposed = true; + return true; + }()); + } + + bool _debugAssertNotDisposed() { + assert(() { + if (_debugDisposed) { + throw FlutterError( + 'A $runtimeType was used after being disposed.\n' + 'Once you have called dispose() on a $runtimeType, it ' + 'can no longer be used.', + ); + } + return true; + }()); + return true; + } + + @override + Future didRequestAppExit() async { + assert(_debugAssertNotDisposed()); + if (onExitRequested == null) { + return AppExitResponse.exit; + } + return onExitRequested!(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + assert(_debugAssertNotDisposed()); + final AppLifecycleState? previousState = _lifecycleState; + if (state == previousState) { + // Transitioning to the same state twice doesn't produce any + // notifications (but also won't actually occur). + return; + } + _lifecycleState = state; + switch (state) { + case AppLifecycleState.resumed: + assert(previousState == null || previousState == AppLifecycleState.inactive || previousState == AppLifecycleState.detached, 'Invalid state transition from $previousState to $state'); + onResume?.call(); + case AppLifecycleState.inactive: + assert(previousState == null || previousState == AppLifecycleState.hidden || previousState == AppLifecycleState.resumed, 'Invalid state transition from $previousState to $state'); + if (previousState == AppLifecycleState.hidden) { + onShow?.call(); + } else if (previousState == null || previousState == AppLifecycleState.resumed) { + onInactive?.call(); + } + case AppLifecycleState.hidden: + assert(previousState == null || previousState == AppLifecycleState.paused || previousState == AppLifecycleState.inactive, 'Invalid state transition from $previousState to $state'); + if (previousState == AppLifecycleState.paused) { + onRestart?.call(); + } else if (previousState == null || previousState == AppLifecycleState.inactive) { + onHide?.call(); + } + case AppLifecycleState.paused: + assert(previousState == null || previousState == AppLifecycleState.hidden, 'Invalid state transition from $previousState to $state'); + if (previousState == null || previousState == AppLifecycleState.hidden) { + onPause?.call(); + } + case AppLifecycleState.detached: + assert(previousState == null || previousState == AppLifecycleState.paused, 'Invalid state transition from $previousState to $state'); + onDetach?.call(); + } + // At this point, it can't be null anymore. + onStateChange?.call(_lifecycleState!); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('binding', binding)); + properties.add(FlagProperty('onStateChange', value: onStateChange != null, ifTrue: 'onStateChange')); + properties.add(FlagProperty('onInactive', value: onInactive != null, ifTrue: 'onInactive')); + properties.add(FlagProperty('onResume', value: onResume != null, ifTrue: 'onResume')); + properties.add(FlagProperty('onHide', value: onHide != null, ifTrue: 'onHide')); + properties.add(FlagProperty('onShow', value: onShow != null, ifTrue: 'onShow')); + properties.add(FlagProperty('onPause', value: onPause != null, ifTrue: 'onPause')); + properties.add(FlagProperty('onRestart', value: onRestart != null, ifTrue: 'onRestart')); + properties.add(FlagProperty('onExitRequested', value: onExitRequested != null, ifTrue: 'onExitRequested')); + properties.add(FlagProperty('onDetach', value: onDetach != null, ifTrue: 'onDetach')); + } +} diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 93361478f4..33d0e9b518 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -3309,14 +3309,10 @@ class ClipboardStatusNotifier extends ValueNotifier with Widget update(); case AppLifecycleState.detached: case AppLifecycleState.inactive: + case AppLifecycleState.hidden: case AppLifecycleState.paused: // Nothing to do. break; - // ignore: no_default_cases - default: - // TODO(gspencergoog): Remove this and replace with real cases once - // engine change rolls into framework. - break; } } diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 01a07ee053..a24e287203 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -24,6 +24,7 @@ export 'src/widgets/animated_size.dart'; export 'src/widgets/animated_switcher.dart'; export 'src/widgets/annotated_region.dart'; export 'src/widgets/app.dart'; +export 'src/widgets/app_lifecycle_listener.dart'; export 'src/widgets/async.dart'; export 'src/widgets/autocomplete.dart'; export 'src/widgets/autofill.dart'; diff --git a/packages/flutter/test/services/lifecycle_test.dart b/packages/flutter/test/services/lifecycle_test.dart index 27e3451df1..c275691b3c 100644 --- a/packages/flutter/test/services/lifecycle_test.dart +++ b/packages/flutter/test/services/lifecycle_test.dart @@ -9,18 +9,16 @@ import 'package:flutter_test/flutter_test.dart'; void main() { testWidgets('initialLifecycleState is used to init state paused', (WidgetTester tester) async { - // The lifecycleState is null initially in tests as there is no - // initialLifecycleState. - expect(ServicesBinding.instance.lifecycleState, equals(null)); - // Mock the Window to provide paused as the AppLifecycleState + expect(ServicesBinding.instance.lifecycleState, isNull); final TestWidgetsFlutterBinding binding = tester.binding; + binding.resetLifecycleState(); // Use paused as the initial state. binding.platformDispatcher.initialLifecycleStateTestValue = 'AppLifecycleState.paused'; binding.readTestInitialLifecycleStateFromNativeWindow(); // Re-attempt the initialization. // The lifecycleState should now be the state we passed above, // even though no lifecycle event was fired from the platform. - expect(ServicesBinding.instance.lifecycleState.toString(), equals('AppLifecycleState.paused')); + expect(binding.lifecycleState.toString(), equals('AppLifecycleState.paused')); }); testWidgets('Handles all of the allowed states of AppLifecycleState', (WidgetTester tester) async { final TestWidgetsFlutterBinding binding = tester.binding; @@ -31,4 +29,18 @@ void main() { expect(ServicesBinding.instance.lifecycleState.toString(), equals(state.toString())); } }); + test('AppLifecycleState values are in the right order for the state machine to be correct', () { + expect( + AppLifecycleState.values, + equals( + [ + AppLifecycleState.detached, + AppLifecycleState.resumed, + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + ], + ), + ); + }); } diff --git a/packages/flutter/test/widgets/app_lifecycle_listener_test.dart b/packages/flutter/test/widgets/app_lifecycle_listener_test.dart new file mode 100644 index 0000000000..a93b97628b --- /dev/null +++ b/packages/flutter/test/widgets/app_lifecycle_listener_test.dart @@ -0,0 +1,190 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppLifecycleListener listener; + + Future setAppLifeCycleState(AppLifecycleState state) async { + final ByteData? message = const StringCodec().encodeMessage(state.toString()); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage('flutter/lifecycle', message, (_) {}); + } + + Future sendAppExitRequest() async { + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('System.requestAppExit')); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage('flutter/platform', message, (_) {}); + } + + setUp(() async { + WidgetsFlutterBinding.ensureInitialized(); + WidgetsBinding.instance + ..resetEpoch() + ..platformDispatcher.onBeginFrame = null + ..platformDispatcher.onDrawFrame = null; + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.instance; + binding.readTestInitialLifecycleStateFromNativeWindow(); + // Reset the state to detached. Going to paused first makes it a valid + // transition from any state, since the intermediate transitions will be + // generated. + await setAppLifeCycleState(AppLifecycleState.paused); + await setAppLifeCycleState(AppLifecycleState.detached); + }); + + tearDown(() { + listener.dispose(); + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.instance; + binding.resetLifecycleState(); + binding.platformDispatcher.resetInitialLifecycleState(); + assert(TestAppLifecycleListener.registerCount == 0, + 'There were ${TestAppLifecycleListener.registerCount} listeners that were not disposed of in tests.'); + }); + + testWidgets('Default Diagnostics', (WidgetTester tester) async { + listener = TestAppLifecycleListener(binding: tester.binding); + expect(listener.toString(), + equalsIgnoringHashCodes('TestAppLifecycleListener#00000(binding: )')); + }); + + testWidgets('Diagnostics', (WidgetTester tester) async { + Future handleExitRequested() async { + return AppExitResponse.cancel; + } + + listener = TestAppLifecycleListener( + binding: WidgetsBinding.instance, + onExitRequested: handleExitRequested, + onStateChange: (AppLifecycleState _) {}, + ); + expect( + listener.toString(), + equalsIgnoringHashCodes( + 'TestAppLifecycleListener#00000(binding: , onStateChange, onExitRequested)')); + }); + + testWidgets('listens to AppLifecycleState', (WidgetTester tester) async { + final List states = [tester.binding.lifecycleState!]; + void stateChange(AppLifecycleState state) { + states.add(state); + } + + listener = TestAppLifecycleListener( + binding: WidgetsBinding.instance, + onStateChange: stateChange, + ); + expect(states, equals([AppLifecycleState.detached])); + await setAppLifeCycleState(AppLifecycleState.inactive); + // "resumed" is generated. + expect(states, + equals([AppLifecycleState.detached, AppLifecycleState.resumed, AppLifecycleState.inactive])); + await setAppLifeCycleState(AppLifecycleState.resumed); + expect( + states, + equals([ + AppLifecycleState.detached, + AppLifecycleState.resumed, + AppLifecycleState.inactive, + AppLifecycleState.resumed + ])); + }); + + testWidgets('Triggers correct state transition callbacks', (WidgetTester tester) async { + final List transitions = []; + listener = TestAppLifecycleListener( + binding: WidgetsBinding.instance, + onDetach: () => transitions.add('detach'), + onHide: () => transitions.add('hide'), + onInactive: () => transitions.add('inactive'), + onPause: () => transitions.add('pause'), + onRestart: () => transitions.add('restart'), + onResume: () => transitions.add('resume'), + onShow: () => transitions.add('show'), + ); + + // Try "standard" sequence + await setAppLifeCycleState(AppLifecycleState.resumed); + expect(transitions, equals(['resume'])); + await setAppLifeCycleState(AppLifecycleState.inactive); + expect(transitions, equals(['resume', 'inactive'])); + await setAppLifeCycleState(AppLifecycleState.hidden); + expect(transitions, equals(['resume', 'inactive', 'hide'])); + await setAppLifeCycleState(AppLifecycleState.paused); + expect(transitions, equals(['resume', 'inactive', 'hide', 'pause'])); + + // Go back to resume + transitions.clear(); + await setAppLifeCycleState(AppLifecycleState.hidden); + expect(transitions, equals(['restart'])); + await setAppLifeCycleState(AppLifecycleState.inactive); + expect(transitions, equals(['restart', 'show'])); + await setAppLifeCycleState(AppLifecycleState.resumed); + expect(transitions, equals(['restart', 'show', 'resume'])); + + // Generates intermediate states. + transitions.clear(); + await setAppLifeCycleState(AppLifecycleState.paused); + expect(transitions, equals(['inactive', 'hide', 'pause'])); + // Wraps around from pause to detach. + await setAppLifeCycleState(AppLifecycleState.detached); + expect(transitions, equals(['inactive', 'hide', 'pause', 'detach'])); + await setAppLifeCycleState(AppLifecycleState.resumed); + expect(transitions, equals(['inactive', 'hide', 'pause', 'detach', 'resume'])); + await setAppLifeCycleState(AppLifecycleState.paused); + expect(transitions, equals(['inactive', 'hide', 'pause', 'detach', 'resume', 'inactive', 'hide', 'pause'])); + transitions.clear(); + await setAppLifeCycleState(AppLifecycleState.resumed); + expect(transitions, equals(['restart', 'show', 'resume'])); + + // Asserts on bad transitions + await expectLater(() => setAppLifeCycleState(AppLifecycleState.detached), throwsAssertionError); + await setAppLifeCycleState(AppLifecycleState.paused); + await setAppLifeCycleState(AppLifecycleState.detached); + }); + + testWidgets('Receives exit requests', (WidgetTester tester) async { + bool exitRequested = false; + Future handleExitRequested() async { + exitRequested = true; + return AppExitResponse.cancel; + } + + listener = TestAppLifecycleListener( + binding: WidgetsBinding.instance, + onExitRequested: handleExitRequested, + ); + await sendAppExitRequest(); + expect(exitRequested, isTrue); + }); +} + +class TestAppLifecycleListener extends AppLifecycleListener { + TestAppLifecycleListener({ + super.binding, + super.onResume, + super.onInactive, + super.onHide, + super.onShow, + super.onPause, + super.onRestart, + super.onDetach, + super.onExitRequested, + super.onStateChange, + }) { + registerCount += 1; + } + + static int registerCount = 0; + + @override + void dispose() { + super.dispose(); + registerCount -= 1; + } +} diff --git a/packages/flutter/test/widgets/binding_test.dart b/packages/flutter/test/widgets/binding_test.dart index d4acb9a8e8..928fc1fa94 100644 --- a/packages/flutter/test/widgets/binding_test.dart +++ b/packages/flutter/test/widgets/binding_test.dart @@ -17,11 +17,11 @@ class MemoryPressureObserver with WidgetsBindingObserver { } class AppLifecycleStateObserver with WidgetsBindingObserver { - late AppLifecycleState lifecycleState; + List accumulatedStates = []; @override void didChangeAppLifecycleState(AppLifecycleState state) { - lifecycleState = state; + accumulatedStates.add(state); } } @@ -66,19 +66,58 @@ void main() { final AppLifecycleStateObserver observer = AppLifecycleStateObserver(); WidgetsBinding.instance.addObserver(observer); - setAppLifeCycleState(AppLifecycleState.paused); - expect(observer.lifecycleState, AppLifecycleState.paused); + await setAppLifeCycleState(AppLifecycleState.paused); + expect(observer.accumulatedStates, [AppLifecycleState.paused]); - setAppLifeCycleState(AppLifecycleState.resumed); - expect(observer.lifecycleState, AppLifecycleState.resumed); + observer.accumulatedStates.clear(); + await setAppLifeCycleState(AppLifecycleState.resumed); + expect(observer.accumulatedStates, [ + AppLifecycleState.hidden, + AppLifecycleState.inactive, + AppLifecycleState.resumed, + ]); - setAppLifeCycleState(AppLifecycleState.inactive); - expect(observer.lifecycleState, AppLifecycleState.inactive); + observer.accumulatedStates.clear(); + await setAppLifeCycleState(AppLifecycleState.paused); + expect(observer.accumulatedStates, [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + ]); - setAppLifeCycleState(AppLifecycleState.detached); - expect(observer.lifecycleState, AppLifecycleState.detached); + observer.accumulatedStates.clear(); + await setAppLifeCycleState(AppLifecycleState.inactive); + expect(observer.accumulatedStates, [ + AppLifecycleState.hidden, + AppLifecycleState.inactive, + ]); - setAppLifeCycleState(AppLifecycleState.resumed); + observer.accumulatedStates.clear(); + await setAppLifeCycleState(AppLifecycleState.hidden); + expect(observer.accumulatedStates, [ + AppLifecycleState.hidden, + ]); + + observer.accumulatedStates.clear(); + await setAppLifeCycleState(AppLifecycleState.paused); + expect(observer.accumulatedStates, [ + AppLifecycleState.paused, + ]); + + observer.accumulatedStates.clear(); + await setAppLifeCycleState(AppLifecycleState.detached); + expect(observer.accumulatedStates, [ + AppLifecycleState.detached, + ]); + + observer.accumulatedStates.clear(); + await setAppLifeCycleState(AppLifecycleState.resumed); + expect(observer.accumulatedStates, [ + AppLifecycleState.resumed, + ]); + + observer.accumulatedStates.clear(); + await expectLater(() async => setAppLifeCycleState(AppLifecycleState.detached), throwsAssertionError); }); testWidgets('didPushRoute callback', (WidgetTester tester) async { @@ -87,7 +126,7 @@ void main() { const String testRouteName = 'testRouteName'; final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('pushRoute', testRouteName)); - await tester.binding.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) { }); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) {}); expect(observer.pushedRoute, testRouteName); WidgetsBinding.instance.removeObserver(observer); @@ -199,26 +238,29 @@ void main() { testWidgets('Application lifecycle affects frame scheduling', (WidgetTester tester) async { expect(tester.binding.hasScheduledFrame, isFalse); - setAppLifeCycleState(AppLifecycleState.paused); + await setAppLifeCycleState(AppLifecycleState.paused); expect(tester.binding.hasScheduledFrame, isFalse); - setAppLifeCycleState(AppLifecycleState.resumed); + await setAppLifeCycleState(AppLifecycleState.resumed); expect(tester.binding.hasScheduledFrame, isTrue); await tester.pump(); expect(tester.binding.hasScheduledFrame, isFalse); - setAppLifeCycleState(AppLifecycleState.inactive); + await setAppLifeCycleState(AppLifecycleState.inactive); expect(tester.binding.hasScheduledFrame, isFalse); - setAppLifeCycleState(AppLifecycleState.detached); + await setAppLifeCycleState(AppLifecycleState.paused); expect(tester.binding.hasScheduledFrame, isFalse); - setAppLifeCycleState(AppLifecycleState.inactive); + await setAppLifeCycleState(AppLifecycleState.detached); + expect(tester.binding.hasScheduledFrame, isFalse); + + await setAppLifeCycleState(AppLifecycleState.inactive); expect(tester.binding.hasScheduledFrame, isTrue); await tester.pump(); expect(tester.binding.hasScheduledFrame, isFalse); - setAppLifeCycleState(AppLifecycleState.paused); + await setAppLifeCycleState(AppLifecycleState.paused); expect(tester.binding.hasScheduledFrame, isFalse); tester.binding.scheduleFrame(); @@ -242,7 +284,7 @@ void main() { expect(frameCount, 1); // Get the tester back to a resumed state for subsequent tests. - setAppLifeCycleState(AppLifecycleState.resumed); + await setAppLifeCycleState(AppLifecycleState.resumed); expect(tester.binding.hasScheduledFrame, isTrue); await tester.pump(); }); diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart index 1a982cf5ef..a8d16b15f4 100644 --- a/packages/flutter_test/lib/src/binding.dart +++ b/packages/flutter_test/lib/src/binding.dart @@ -379,7 +379,7 @@ abstract class TestWidgetsFlutterBinding extends BindingBase @override void initInstances() { - // This is intialized here because it's needed for the `super.initInstances` + // This is initialized here because it's needed for the `super.initInstances` // call. It can't be handled as a ctor initializer because it's dependent // on `platformDispatcher`. It can't be handled in the ctor itself because // the base class ctor is called first and calls `initInstances`. @@ -499,6 +499,17 @@ abstract class TestWidgetsFlutterBinding extends BindingBase }); } + @override + Future exitApplication(ui.AppExitType exitType, [int exitCode = 0]) async { + switch (exitType) { + case ui.AppExitType.cancelable: + // The test framework shouldn't actually exit when requested. + return ui.AppExitResponse.cancel; + case ui.AppExitType.required: + throw FlutterError('Unexpected application exit request while running test'); + } + } + /// Re-attempts the initialization of the lifecycle state after providing /// test values in [TestWindow.initialLifecycleStateTestValue]. void readTestInitialLifecycleStateFromNativeWindow() { @@ -936,8 +947,8 @@ abstract class TestWidgetsFlutterBinding extends BindingBase try { treeDump = rootElement?.toDiagnosticsNode() ?? DiagnosticsNode.message(''); // We try to stringify the tree dump here (though we immediately discard the result) because - // we want to make sure that if it can't be serialised, we replace it with a message that - // says the tree could not be serialised. Otherwise, the real exception might get obscured + // we want to make sure that if it can't be serialized, we replace it with a message that + // says the tree could not be serialized. Otherwise, the real exception might get obscured // by side-effects of the underlying issues causing the tree dumping code to flail. treeDump.toStringDeep(); } catch (exception) {