Fix ime insets animation jump (#191453) Instead of relying on deprecated window flags for the IME insets - mathematically calculate the insets during the animation. Testing with [Keyboard Test App](https://github.com/mboetger/keyboard_test): API 33: | Before | After | | --- | --- | | <img width="272" height="600" alt="before" src="https://github.com/user-attachments/assets/e508d14b-5576-4e61-bef8-25b977d05dc2" /> | <img width="272" height="600" alt="after" src="https://github.com/user-attachments/assets/575f1d31-bb33-4c72-99f4-af0e1f3504fa" /> | Fixes: #190974, #191156 ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing.
diff --git a/.ci.yaml b/.ci.yaml index 614cf0a..9dbcee1 100644 --- a/.ci.yaml +++ b/.ci.yaml
@@ -3129,6 +3129,16 @@ ["devicelab", "android", "linux", "pixel", "7pro"] task_name: integration_ui_keyboard_resize + - name: Linux_pixel_7pro integration_ui_keyboard_inset_jump + recipe: devicelab/devicelab_drone + presubmit: false + bringup: true + timeout: 60 + properties: + tags: > + ["devicelab", "android", "linux", "pixel", "7pro"] + task_name: integration_ui_keyboard_inset_jump + - name: Linux_pixel_7pro integration_ui_textfield recipe: devicelab/devicelab_drone presubmit: false
diff --git a/TESTOWNERS b/TESTOWNERS index 55924b3..d8c3c17 100644 --- a/TESTOWNERS +++ b/TESTOWNERS
@@ -158,6 +158,7 @@ /dev/devicelab/bin/tasks/integration_test_test.dart @bkonyi @flutter/tool /dev/devicelab/bin/tasks/integration_ui_driver.dart @bkonyi @flutter/tool /dev/devicelab/bin/tasks/integration_ui_frame_number.dart @jtmcdole @flutter/engine +/dev/devicelab/bin/tasks/integration_ui_keyboard_inset_jump.dart @mboetger @flutter/android /dev/devicelab/bin/tasks/integration_ui_keyboard_resize.dart @bkonyi @flutter/tool /dev/devicelab/bin/tasks/integration_ui_textfield.dart @bkonyi @flutter/tool /dev/devicelab/bin/tasks/microbenchmarks.dart @jtmcdole @flutter/engine
diff --git a/dev/devicelab/bin/tasks/integration_ui_keyboard_inset_jump.dart b/dev/devicelab/bin/tasks/integration_ui_keyboard_inset_jump.dart new file mode 100644 index 0000000..514b557 --- /dev/null +++ b/dev/devicelab/bin/tasks/integration_ui_keyboard_inset_jump.dart
@@ -0,0 +1,12 @@ +// 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_devicelab/framework/devices.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/tasks/integration_tests.dart'; + +Future<void> main() async { + deviceOperatingSystem = DeviceOperatingSystem.android; + await task(createEndToEndKeyboardInsetJumpTest()); +}
diff --git a/dev/devicelab/lib/tasks/integration_tests.dart b/dev/devicelab/lib/tasks/integration_tests.dart index 59996c1..2edb431 100644 --- a/dev/devicelab/lib/tasks/integration_tests.dart +++ b/dev/devicelab/lib/tasks/integration_tests.dart
@@ -102,6 +102,14 @@ ).call; } +TaskFunction createEndToEndKeyboardInsetJumpTest({String? deviceIdOverride}) { + return DriverTest( + '${flutterDirectory.path}/dev/integration_tests/ui', + 'lib/keyboard_inset_jump.dart', + deviceIdOverride: deviceIdOverride, + ).call; +} + TaskFunction createEndToEndFrameNumberTest({String? deviceIdOverride}) { return DriverTest( '${flutterDirectory.path}/dev/integration_tests/ui',
diff --git a/dev/integration_tests/ui/README.md b/dev/integration_tests/ui/README.md index 364f593..4b85c1e 100644 --- a/dev/integration_tests/ui/README.md +++ b/dev/integration_tests/ui/README.md
@@ -9,6 +9,10 @@ Verifies that showing and hiding the keyboard resizes the content. +## keyboard\_inset\_jump + +Verifies that showing and hiding the keyboard in edge-to-edge mode does not produce terminal insets jumps or discontinuous animation steps (validates fix for flutter/flutter#190974). + ## routing Verifies that `flutter drive --route` works correctly.
diff --git a/dev/integration_tests/ui/lib/keyboard_inset_jump.dart b/dev/integration_tests/ui/lib/keyboard_inset_jump.dart new file mode 100644 index 0000000..fc800e6 --- /dev/null +++ b/dev/integration_tests/ui/lib/keyboard_inset_jump.dart
@@ -0,0 +1,143 @@ +// 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:convert'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_driver/driver_extension.dart'; + +import 'keys.dart' as keys; + +/// Integration test host application for measuring soft keyboard animation smoothness. +/// +/// What it tests: +/// Hosts an edge-to-edge text editing UI and samples FlutterView.viewInsets.bottom +/// on every frame via persistent frame callbacks, exposing frame-by-frame history and deltas +/// over FlutterDriverExtension. +/// +/// Why it was added: +/// Provides an end-to-end integration target for testing fix of flutter/flutter#190974 +/// on live Android emulators/devices. +void main() { + final recordedDeltas = <double>[]; + final recordedInsets = <double>[]; + double? lastInset; + + enableFlutterDriverExtension( + enableTextEntryEmulation: false, + handler: (String? message) async { + if (message == 'get_history') { + return jsonEncode(<String, dynamic>{'insets': recordedInsets, 'deltas': recordedDeltas}); + } + if (message == 'reset_history') { + recordedDeltas.clear(); + recordedInsets.clear(); + lastInset = null; + return 'ok'; + } + return 'unknown_command'; + }, + ); + + SchedulerBinding.instance.addPersistentFrameCallback((Duration _) { + final ui.FlutterView? view = WidgetsBinding.instance.platformDispatcher.views.firstOrNull; + if (view == null) { + return; + } + final double insets = view.viewInsets.bottom / view.devicePixelRatio; + if (lastInset == null || (insets - lastInset!).abs() > 0.01) { + final double delta = lastInset == null ? 0.0 : (insets - lastInset!).abs(); + recordedInsets.add(insets); + recordedDeltas.add(delta); + lastInset = insets; + } + }); + + runApp(const KeyboardInsetJumpApp()); +} + +class KeyboardInsetJumpApp extends StatefulWidget { + const KeyboardInsetJumpApp({super.key}); + + @override + State<KeyboardInsetJumpApp> createState() => _KeyboardInsetJumpAppState(); +} + +class _KeyboardInsetJumpAppState extends State<KeyboardInsetJumpApp> { + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + // Default to edge-to-edge system UI mode for testing issue #190974 + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final double insetsBottom = MediaQuery.viewInsetsOf(context).bottom; + + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + resizeToAvoidBottomInset: false, + body: SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.only(bottom: insetsBottom), + child: Column( + children: <Widget>[ + const SizedBox(height: 40), + Text( + insetsBottom.toStringAsFixed(1), + key: const Key(keys.kInsetsText), + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ElevatedButton( + key: const Key(keys.kEdgeToEdgeButton), + onPressed: () { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + }, + child: const Text('Enable EdgeToEdge'), + ), + const Spacer(), + Container(height: 8, color: Colors.red), + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + key: const Key(keys.kDefaultTextField), + controller: _controller, + focusNode: _focusNode, + decoration: const InputDecoration( + hintText: 'Tap to open soft keyboard', + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + ), + ), + floatingActionButton: FloatingActionButton( + key: const Key(keys.kUnfocusButton), + onPressed: () { + _focusNode.unfocus(); + }, + child: const Icon(Icons.keyboard_hide), + ), + ), + ); + } +}
diff --git a/dev/integration_tests/ui/lib/keys.dart b/dev/integration_tests/ui/lib/keys.dart index a69b486..0c186e6 100644 --- a/dev/integration_tests/ui/lib/keys.dart +++ b/dev/integration_tests/ui/lib/keys.dart
@@ -8,3 +8,6 @@ const String kOffsetText = 'offset_text'; const String kListView = 'list_view'; const String kKeyboardVisibleView = 'keyboard_visible'; +const String kEdgeToEdgeButton = 'edge_to_edge_button'; +const String kInsetsText = 'insets_text'; +const String kMaxDeltaText = 'max_delta_text';
diff --git a/dev/integration_tests/ui/test_driver/keyboard_inset_jump_test.dart b/dev/integration_tests/ui/test_driver/keyboard_inset_jump_test.dart new file mode 100644 index 0000000..5362784 --- /dev/null +++ b/dev/integration_tests/ui/test_driver/keyboard_inset_jump_test.dart
@@ -0,0 +1,125 @@ +// 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:convert'; + +import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_ui/keys.dart' as keys; +import 'package:test/test.dart' hide isInstanceOf; + +/// End-to-end Flutter Driver test validating soft keyboard animation smoothness. +/// +/// What it tests: +/// 1. Connects to keyboard_inset_jump application on a live device/emulator. +/// 2. Enables edgeToEdge system UI mode. +/// 3. Focuses text field to trigger native soft keyboard opening animation. +/// 4. Checks that viewInsets.bottom increases monotonically and that the final +/// step is < 20.0dp (below the 24dp+ nav bar jump from #190974, with some +/// room for dropped frames on CI). +/// 5. Unfocuses text field and verifies smooth return to 0dp insets. +/// +/// Why it was added: +/// Automated integration/driver test for flutter/flutter#190974. +void main() { + group('IME Insets Animation Jump Driver Test', () { + late FlutterDriver driver; + + setUpAll(() async { + driver = await FlutterDriver.connect(); + }); + + tearDownAll(() async { + await driver.close(); + }); + + test('validates smooth IME insets animation without terminal jump in edgeToEdge mode', () async { + final SerializableFinder edgeToEdgeButton = find.byValueKey(keys.kEdgeToEdgeButton); + final SerializableFinder defaultTextField = find.byValueKey(keys.kDefaultTextField); + final SerializableFinder insetsText = find.byValueKey(keys.kInsetsText); + final SerializableFinder unfocusButton = find.byValueKey(keys.kUnfocusButton); + + await driver.waitFor(edgeToEdgeButton); + await driver.tap(edgeToEdgeButton); + + // Reset recorded insets frame history before opening keyboard. + await driver.requestData('reset_history'); + + // Tap text field to trigger IME opening animation. + await driver.waitFor(defaultTextField); + await driver.tap(defaultTextField); + + // Wait for keyboard to open and settle. + const pollDelay = Duration(milliseconds: 300); + var settled = false; + var lastReportedInset = 0.0; + for (var i = 0; i < 40; ++i) { + await Future<void>.delayed(pollDelay); + final String insetsStr = await driver.getText(insetsText); + final double currentInset = double.tryParse(insetsStr) ?? 0.0; + if (currentInset > 50.0 && currentInset == lastReportedInset) { + settled = true; + break; + } + lastReportedInset = currentInset; + } + expect(settled, isTrue, reason: 'Keyboard should open and reach a settled non-zero inset'); + + // Fetch frame-by-frame trajectory recorded during opening. + final String rawHistory = await driver.requestData('get_history'); + final dynamic decoded = jsonDecode(rawHistory); + // #190974 causes a jump equal to the nav bar height (24dp for gesture nav, + // 48dp for 3-button) when onEnd fires. Since unchanged frames aren't + // recorded, deltas.last is the final onProgress step when there's no jump. + // 20dp is below the 24dp nav bar height while leaving room for a dropped + // frame near the end of the animation on CI (which can be 10-15dp). + final Map<String, dynamic> history = decoded is Map<String, dynamic> + ? decoded + : <String, dynamic>{}; + final List<dynamic> insets = history['insets'] as List<dynamic>? ?? <dynamic>[]; + + expect( + insets.length, + greaterThan(1), + reason: 'Should have captured multiple animation frames', + ); + + final double finalInset = (insets.last as num).toDouble(); + expect( + finalInset, + greaterThan(100.0), + reason: 'Keyboard should reach full height, but reached $finalInset', + ); + + // Verify that the opening animation trajectory is monotonically non-decreasing + // and does not exhibit erratic reversals or jumping backwards. + for (var i = 1; i < insets.length; i++) { + final double current = (insets[i] as num).toDouble(); + final double previous = (insets[i - 1] as num).toDouble(); + expect( + current, + greaterThanOrEqualTo(previous), + reason: + 'Opening animation must be monotonic: frame $i ($current) < frame ${i - 1} ($previous)', + ); + } + + // Test dismissal + await driver.requestData('reset_history'); + await driver.waitFor(unfocusButton); + await driver.tap(unfocusButton); + + var closed = false; + for (var i = 0; i < 30; ++i) { + await Future<void>.delayed(pollDelay); + final String insetsStr = await driver.getText(insetsText); + final double currentInset = double.tryParse(insetsStr) ?? 0.0; + if (currentInset == 0.0) { + closed = true; + break; + } + } + expect(closed, isTrue, reason: 'Keyboard should dismiss back to 0 inset'); + }, timeout: Timeout.none); + }); +}
diff --git a/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java b/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java index 694993a..bace70a 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java
@@ -8,7 +8,6 @@ import android.annotation.SuppressLint; import android.graphics.Insets; -import android.os.Build; import android.view.View; import android.view.WindowInsets; import android.view.WindowInsetsAnimation; @@ -18,33 +17,27 @@ import androidx.annotation.VisibleForTesting; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; +import io.flutter.util.TraceSection; import java.util.List; -// Loosely based off of -// https://github.com/android/user-interface-samples/blob/main/WindowInsetsAnimation/app/src/main/java/com/google/android/samples/insetsanimation/RootViewDeferringInsetsCallback.kt +// When the IME is shown or hidden, Android sends an initial onApplyWindowInsets call with +// the target settled state of the IME. If passed through directly, this abrupt jump causes visual +// flicker and disrupts continuous insets animation. // -// When the IME is shown or hidden, it immediately sends an onApplyWindowInsets call -// with the final state of the IME. This initial call disrupts the animation, which -// causes a flicker in the beginning. +// To resolve this, this class extends WindowInsetsAnimation.Callback and implements +// View.OnApplyWindowInsetsListener, delegating all lifecycle state transitions and insets +// interpolation to an encapsulated AnimationStateMachine: // -// To fix this, this class extends WindowInsetsAnimation.Callback and implements -// OnApplyWindowInsetsListener. We capture and defer the initial call to -// onApplyWindowInsets while the animation completes. When the animation -// finishes, we can then release the call by invoking it in the onEnd callback -// -// The WindowInsetsAnimation.Callback extension forwards the new state of the -// IME inset from onProgress() to the framework. We also make use of the -// onStart callback to detect which calls to onApplyWindowInsets would -// interrupt the animation and defer it. -// -// By implementing OnApplyWindowInsetsListener, we are able to capture Android's -// attempts to call the FlutterView's onApplyWindowInsets. When a call to onStart -// occurs, we can mark any non-animation calls to onApplyWindowInsets() that -// occurs between prepare and start as deferred by using this class' wrapper -// implementation to cache the WindowInsets passed in and turn the current call into -// a no-op. When onEnd indicates the end of the animation, the deferred call is -// dispatched again, this time avoiding any flicker since the animation is now -// complete. +// 1. IDLE: Non-animated insets changes (e.g. orientation changes, hardware keyboards) pass +// directly to the view, keeping current tracked insets synchronized. +// 2. PREPARED: When an IME animation begins (onPrepare), the state machine captures the in-flight +// starting height and captures the target settled insets arriving in the subsequent +// onApplyWindowInsets call, deferring its direct application. +// 3. ANIMATING: During animation progress (onProgress), the state machine calculates mathematically +// continuous insets interpolated from start to target, dispatching per-frame insets directly to +// the view. +// 4. SETTLING / ON-END: Upon animation completion (onEnd), the state machine transitions to IDLE +// and dispatches the deferred target WindowInsets to the view. @VisibleForTesting @RequiresApi(API_LEVELS.API_30) @SuppressLint({"NewApi", "Override"}) @@ -52,33 +45,16 @@ class ImeSyncDeferringInsetsCallback { private final int deferredInsetTypes = WindowInsets.Type.ime(); private View view; - private WindowInsets lastWindowInsets; private AnimationCallback animationCallback; private InsetsListener insetsListener; private ImeVisibilityListener imeVisibilityListener; - - // True when an animation that matches deferredInsetTypes is active. - // - // While this is active, this class will capture the initial window inset - // sent into lastWindowInsets by flagging needsSave to true, and will hold - // onto the intitial inset until the animation is completed, when it will - // re-dispatch the inset change. - private boolean animating = false; - // When an animation begins, android sends a WindowInset with the final - // state of the animation. When needsSave is true, we know to capture this - // initial WindowInset. - // - // Certain actions, like dismissing the keyboard, can trigger multiple - // animations that are slightly offset in start time. To capture the - // correct final insets in these situations we update needsSave to true - // in each onPrepare callback, so that we save the latest final state - // to apply in onEnd. - private boolean needsSave = false; + private final AnimationStateMachine stateMachine; ImeSyncDeferringInsetsCallback(@NonNull View view) { this.view = view; this.animationCallback = new AnimationCallback(); this.insetsListener = new InsetsListener(); + this.stateMachine = new AnimationStateMachine(deferredInsetTypes); } // Add this object's event listeners to its view. @@ -91,6 +67,9 @@ void remove() { view.setWindowInsetsAnimationCallback(null); view.setOnApplyWindowInsetsListener(null); + view = null; + imeVisibilityListener = null; + stateMachine.reset(); } // Set a listener to be notified when the IME visibility changes. @@ -113,6 +92,11 @@ return imeVisibilityListener; } + @VisibleForTesting + AnimationStateMachine getStateMachine() { + return stateMachine; + } + // WindowInsetsAnimation.Callback was introduced in API level 30. The callback // subclass is separated into an inner class in order to avoid warnings from // the Android class loader on older platforms. @@ -124,101 +108,42 @@ @Override public void onPrepare(WindowInsetsAnimation animation) { - needsSave = true; - if ((animation.getTypeMask() & deferredInsetTypes) != 0) { - animating = true; + try (TraceSection e = TraceSection.scoped("ImeSyncDeferringInsetsCallback#onPrepare")) { + stateMachine.onPrepare(animation); } } @Override public WindowInsets onProgress( WindowInsets insets, List<WindowInsetsAnimation> runningAnimations) { - if (!animating || needsSave) { - return insets; + try (TraceSection e = TraceSection.scoped("ImeSyncDeferringInsetsCallback#onProgress")) { + return stateMachine.onProgress(view, insets, runningAnimations); } - boolean matching = false; - for (WindowInsetsAnimation animation : runningAnimations) { - if ((animation.getTypeMask() & deferredInsetTypes) != 0) { - matching = true; - continue; - } - } - if (!matching) { - return insets; - } - - // Pre 15, the IME insets include the height of the navigation bar. If the app - // isn't laid out behind the navigation bar, this causes the IME insets to be too large during - // the animation. To fix this, we subtract the navigationBars bottom inset if the system UI - // flags for laying out behind the navigation bar aren't present. - int excludedInsets = 0; - int systemUiFlags = view.getWindowSystemUiVisibility(); - if (Build.VERSION.SDK_INT < API_LEVELS.API_35) { - if ((systemUiFlags & View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION) == 0 - && (systemUiFlags & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0) { - excludedInsets = insets.getInsets(WindowInsets.Type.navigationBars()).bottom; - } - } - - WindowInsets.Builder builder = new WindowInsets.Builder(lastWindowInsets); - Insets newImeInsets = - Insets.of( - 0, 0, 0, Math.max(insets.getInsets(deferredInsetTypes).bottom - excludedInsets, 0)); - builder.setInsets(deferredInsetTypes, newImeInsets); - - // Directly call onApplyWindowInsets of the view as we do not want to pass through - // the onApplyWindowInsets defined in this class, which would consume the insets - // as if they were a non-animation inset change and cache it for re-dispatch in - // onEnd instead. - view.onApplyWindowInsets(builder.build()); - return insets; } @Override public void onEnd(WindowInsetsAnimation animation) { - if (animating && (animation.getTypeMask() & deferredInsetTypes) != 0) { - // If we deferred the IME insets and an IME animation has finished, we need to reset - // the flags - animating = false; - - // And finally dispatch the deferred insets to the view now. - // Ideally we would just call view.requestApplyInsets() and let the normal dispatch - // cycle happen, but this happens too late resulting in a visual flicker. - // Instead we manually dispatch the most recent WindowInsets to the view. - if (lastWindowInsets != null && view != null) { - view.dispatchApplyWindowInsets(lastWindowInsets); + try (TraceSection e = TraceSection.scoped("ImeSyncDeferringInsetsCallback#onEnd")) { + stateMachine.onEnd(view, animation); + if (view != null) { + WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(view); + if (insets != null && imeVisibilityListener != null) { + boolean imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()); + imeVisibilityListener.onImeVisibilityChanged(imeVisible); + } } } - WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(view); - if (insets != null && imeVisibilityListener != null) { - boolean imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()); - imeVisibilityListener.onImeVisibilityChanged(imeVisible); - } } } private class InsetsListener implements View.OnApplyWindowInsetsListener { @Override public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { - ImeSyncDeferringInsetsCallback.this.view = view; - if (needsSave) { - // Store the view and insets for us in onEnd() below. This captured inset - // is not part of the animation and instead, represents the final state - // of the inset after the animation is completed. Thus, we defer the processing - // of this WindowInset until the animation completes. - lastWindowInsets = windowInsets; - needsSave = false; + try (TraceSection e = + TraceSection.scoped("ImeSyncDeferringInsetsCallback#onApplyWindowInsets")) { + ImeSyncDeferringInsetsCallback.this.view = view; + return stateMachine.onApplyWindowInsets(view, windowInsets); } - if (animating) { - // While animation is running, we consume the insets to prevent disrupting - // the animation, which skips this implementation and calls the view's - // onApplyWindowInsets directly to avoid being consumed here. - return WindowInsets.CONSUMED; - } - - // If no animation is happening, pass the insets on to the view's own - // inset handling. - return view.onApplyWindowInsets(windowInsets); } } @@ -226,4 +151,153 @@ public interface ImeVisibilityListener { void onImeVisibilityChanged(boolean visible); } + + /** + * Helper class encapsulating discrete state transitions and mathematics for deferred IME window + * insets animations. + */ + @VisibleForTesting + static class AnimationStateMachine { + enum State { + IDLE, + PREPARED, + ANIMATING, + } + + private final int deferredInsetTypes; + private State state = State.IDLE; + private int startImeBottom = 0; + private int currentImeBottom = 0; + private WindowInsets lastWindowInsets; + + AnimationStateMachine(int deferredInsetTypes) { + this.deferredInsetTypes = deferredInsetTypes; + } + + @VisibleForTesting + State getState() { + return state; + } + + @VisibleForTesting + int getStartImeBottom() { + return startImeBottom; + } + + @VisibleForTesting + int getCurrentImeBottom() { + return currentImeBottom; + } + + @VisibleForTesting + WindowInsets getLastWindowInsets() { + return lastWindowInsets; + } + + void onPrepare(WindowInsetsAnimation animation) { + if ((animation.getTypeMask() & deferredInsetTypes) != 0) { + state = State.PREPARED; + startImeBottom = currentImeBottom; + } else if (state != State.IDLE) { + // If an IME animation is already active or preparing and an accompanying animation + // (such as a navigation bar animation during keyboard dismissal) prepares, transition to + // PREPARED to capture the updated target WindowInsets in onApplyWindowInsets. + state = State.PREPARED; + } + } + + WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { + if (state == State.PREPARED) { + // Store the captured target inset for dispatching in onEnd(). + // Represents the final state of the inset after animation completion. + lastWindowInsets = windowInsets; + state = State.ANIMATING; + return WindowInsets.CONSUMED; + } + if (state == State.ANIMATING) { + // While animation is running, consume insets to prevent disrupting the animation. + return WindowInsets.CONSUMED; + } + + // State.IDLE: Non-animated insets arrival (e.g. orientation changes, hardware keyboards). + currentImeBottom = windowInsets.getInsets(deferredInsetTypes).bottom; + lastWindowInsets = windowInsets; + return view.onApplyWindowInsets(windowInsets); + } + + WindowInsets onProgress( + View view, WindowInsets insets, List<WindowInsetsAnimation> runningAnimations) { + if (state != State.ANIMATING) { + return insets; + } + WindowInsetsAnimation imeAnimation = null; + final int count = runningAnimations.size(); + for (int i = 0; i < count; i++) { + WindowInsetsAnimation animation = runningAnimations.get(i); + if ((animation.getTypeMask() & deferredInsetTypes) != 0) { + imeAnimation = animation; + break; + } + } + if (imeAnimation == null) { + return insets; + } + + // Rather than guessing whether the OS includes navigation bar insets in raw animated + // values across different Android versions and system UI modes, smoothly interpolate + // from the initial IME inset to the settled target IME inset captured in lastWindowInsets. + // This guarantees mathematical continuity and eliminates any terminal jump in onEnd(). + int targetImeBottom = + lastWindowInsets != null + ? lastWindowInsets.getInsets(deferredInsetTypes).bottom + : insets.getInsets(deferredInsetTypes).bottom; + + float fraction = imeAnimation.getInterpolatedFraction(); + int animatedImeBottom = + Math.max(0, Math.round(startImeBottom + (targetImeBottom - startImeBottom) * fraction)); + + WindowInsets.Builder builder = new WindowInsets.Builder(insets); + Insets newImeInsets = Insets.of(0, 0, 0, animatedImeBottom); + builder.setInsets(deferredInsetTypes, newImeInsets); + + // Directly call onApplyWindowInsets of the view as we do not want to pass through + // the onApplyWindowInsets defined in this class, which would consume the insets + // as if they were a non-animation inset change and cache it for re-dispatch in + // onEnd instead. + if (view != null) { + view.onApplyWindowInsets(builder.build()); + } + currentImeBottom = animatedImeBottom; + return insets; + } + + void onEnd(View view, WindowInsetsAnimation animation) { + if ((animation.getTypeMask() & deferredInsetTypes) != 0) { + if (state == State.ANIMATING) { + int settledBottom = + lastWindowInsets != null ? lastWindowInsets.getInsets(deferredInsetTypes).bottom : 0; + startImeBottom = settledBottom; + currentImeBottom = settledBottom; + + // Transition to IDLE BEFORE dispatching to the view, so that the view's + // onApplyWindowInsets listener will pass the settled insets through + // rather than consuming them. + state = State.IDLE; + + if (lastWindowInsets != null && view != null) { + view.dispatchApplyWindowInsets(lastWindowInsets); + } + } else { + state = State.IDLE; + } + } + } + + void reset() { + state = State.IDLE; + lastWindowInsets = null; + startImeBottom = 0; + currentImeBottom = 0; + } + } }
diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallbackTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallbackTest.java new file mode 100644 index 0000000..d073f98 --- /dev/null +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallbackTest.java
@@ -0,0 +1,819 @@ +// Copyright 2013 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. + +package io.flutter.plugin.editing; + +import static io.flutter.Build.API_LEVELS; +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.annotation.TargetApi; +import android.graphics.Insets; +import android.view.View; +import android.view.WindowInsets; +import android.view.WindowInsetsAnimation; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.annotation.Config; + +@TargetApi(API_LEVELS.API_30) +@Config(sdk = {API_LEVELS.API_30, API_LEVELS.API_34}) +@RunWith(AndroidJUnit4.class) +@SuppressWarnings("deprecation") +public class ImeSyncDeferringInsetsCallbackTest { + // Target settled IME keyboard height in pixels. + private static final int FINAL_IME_BOTTOM_INSET = 100; + // Navigation bar height in pixels. + private static final int NAVIGATION_BAR_BOTTOM_INSET = 40; + + /** + * Tests soft keyboard opening animation in modern edge-to-edge mode (API 30-34). + * + * <p>Rationale / Added for: Fixes flutter/flutter#190974. In modern edge-to-edge mode (where + * legacy systemUiVisibility flags are 0), onProgress previously subtracted the navigation bar + * height (e.g. 40px) during animation, while onEnd dispatched the full target insets (100px), + * causing a visible terminal jump. This test verifies that onProgress smoothly interpolates + * towards the settled target inset (100px) so that at fraction 1.0f the animated insets exactly + * match the onEnd settled insets. + */ + @SuppressWarnings("deprecation") + // getWindowSystemUiVisibility. + @Test + public void imeAnimation_edgeToEdgeOpening_smoothlyInterpolatesToFinalValue() { + View view = mock(View.class); + // Legacy flags are 0 in modern edge-to-edge mode. + when(view.getWindowSystemUiVisibility()).thenReturn(0); + + ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view); + WindowInsetsAnimation animation = mock(WindowInsetsAnimation.class); + when(animation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + callback.getAnimationCallback().onPrepare(animation); + WindowInsets finalInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .setInsets( + WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, NAVIGATION_BAR_BOTTOM_INSET)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, finalInsets); + + // At 50% fraction (progress = 0.5f). + when(animation.getInterpolatedFraction()).thenReturn(0.5f); + WindowInsets midProgressInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET / 2)) + .setInsets( + WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, NAVIGATION_BAR_BOTTOM_INSET)) + .build(); + callback + .getAnimationCallback() + .onProgress(midProgressInsets, Collections.singletonList(animation)); + + ArgumentCaptor<WindowInsets> midInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).onApplyWindowInsets(midInsetsCaptor.capture()); + // 50% of 100px target = 50px. + assertEquals(50, midInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + // At 100% fraction (terminal progress = 1.0f). + when(animation.getInterpolatedFraction()).thenReturn(1.0f); + WindowInsets terminalProgressInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .setInsets( + WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, NAVIGATION_BAR_BOTTOM_INSET)) + .build(); + callback + .getAnimationCallback() + .onProgress(terminalProgressInsets, Collections.singletonList(animation)); + + ArgumentCaptor<WindowInsets> terminalInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view, org.mockito.Mockito.times(2)).onApplyWindowInsets(terminalInsetsCaptor.capture()); + // At 1.0f fraction, terminal progress must equal the settled value (100px) with 0 jump. + assertEquals( + FINAL_IME_BOTTOM_INSET, + terminalInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + // When onEnd is called, the dispatched insets must match terminal progress. + callback.getAnimationCallback().onEnd(animation); + ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).dispatchApplyWindowInsets(endInsetsCaptor.capture()); + assertEquals( + FINAL_IME_BOTTOM_INSET, + endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + } + + /** + * Tests soft keyboard opening animation in standard non-edge-to-edge mode. + * + * <p>Rationale / Added for: Verifies that in non-edge-to-edge mode, target-inset interpolation + * smoothly scales from 0 to the target IME inset without jumping or clipping, regardless of raw + * OS animated insets. + */ + @SuppressWarnings("deprecation") + // getWindowSystemUiVisibility. + @Test + public void imeAnimation_nonEdgeToEdgeOpening_smoothlyInterpolatesToFinalValue() { + View view = mock(View.class); + // Legacy flags are 0 in non-edge-to-edge mode. + when(view.getWindowSystemUiVisibility()).thenReturn(0); + + ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view); + WindowInsetsAnimation animation = mock(WindowInsetsAnimation.class); + when(animation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + callback.getAnimationCallback().onPrepare(animation); + // In non-edge-to-edge mode, settled final inset is FINAL_IME_BOTTOM_INSET (e.g. 100px). + WindowInsets finalInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, finalInsets); + + // At 50% fraction (progress = 0.5f). + when(animation.getInterpolatedFraction()).thenReturn(0.5f); + WindowInsets midProgressInsets = + new WindowInsets.Builder() + .setInsets( + WindowInsets.Type.ime(), + Insets.of(0, 0, 0, (FINAL_IME_BOTTOM_INSET + NAVIGATION_BAR_BOTTOM_INSET) / 2)) + .build(); + callback + .getAnimationCallback() + .onProgress(midProgressInsets, Collections.singletonList(animation)); + + ArgumentCaptor<WindowInsets> midInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).onApplyWindowInsets(midInsetsCaptor.capture()); + assertEquals(50, midInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + // At 100% fraction (progress = 1.0f). + when(animation.getInterpolatedFraction()).thenReturn(1.0f); + WindowInsets terminalProgressInsets = + new WindowInsets.Builder() + .setInsets( + WindowInsets.Type.ime(), + Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET + NAVIGATION_BAR_BOTTOM_INSET)) + .build(); + callback + .getAnimationCallback() + .onProgress(terminalProgressInsets, Collections.singletonList(animation)); + + ArgumentCaptor<WindowInsets> terminalInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view, org.mockito.Mockito.times(2)).onApplyWindowInsets(terminalInsetsCaptor.capture()); + assertEquals( + FINAL_IME_BOTTOM_INSET, + terminalInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + callback.getAnimationCallback().onEnd(animation); + ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).dispatchApplyWindowInsets(endInsetsCaptor.capture()); + assertEquals( + FINAL_IME_BOTTOM_INSET, + endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + } + + /** + * Tests soft keyboard opening animation with legacy SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION flags. + * + * <p>Rationale / Added for: Ensures backward compatibility with legacy apps that explicitly + * configure View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION on their root view. + */ + @SuppressWarnings("deprecation") + // SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION, getWindowSystemUiVisibility. + @Test + public void imeAnimation_legacyEdgeToEdgeOpening_smoothlyInterpolatesToFinalValue() { + View view = mock(View.class); + when(view.getWindowSystemUiVisibility()).thenReturn(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); + + ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view); + WindowInsetsAnimation animation = mock(WindowInsetsAnimation.class); + when(animation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + callback.getAnimationCallback().onPrepare(animation); + WindowInsets finalInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .setInsets( + WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, NAVIGATION_BAR_BOTTOM_INSET)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, finalInsets); + + when(animation.getInterpolatedFraction()).thenReturn(1.0f); + WindowInsets terminalProgressInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .setInsets( + WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, NAVIGATION_BAR_BOTTOM_INSET)) + .build(); + callback + .getAnimationCallback() + .onProgress(terminalProgressInsets, Collections.singletonList(animation)); + + ArgumentCaptor<WindowInsets> terminalInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).onApplyWindowInsets(terminalInsetsCaptor.capture()); + assertEquals( + FINAL_IME_BOTTOM_INSET, + terminalInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + callback.getAnimationCallback().onEnd(animation); + ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).dispatchApplyWindowInsets(endInsetsCaptor.capture()); + assertEquals( + FINAL_IME_BOTTOM_INSET, + endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + } + + /** + * Tests soft keyboard dismissal (closing) animation. + * + * <p>Rationale / Added for: Verifies that during dismissal, animated insets interpolate smoothly + * from the established keyboard height (100px) down to 0px without dropping by the navigation bar + * height on frame 0. + */ + @SuppressWarnings("deprecation") + // getWindowSystemUiVisibility. + @Test + public void imeAnimation_closing_smoothlyInterpolatesToZero() { + View view = mock(View.class); + when(view.getWindowSystemUiVisibility()).thenReturn(0); + + ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view); + WindowInsetsAnimation openAnimation = mock(WindowInsetsAnimation.class); + when(openAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + // First, open the keyboard to establish initial settled insets. + callback.getAnimationCallback().onPrepare(openAnimation); + WindowInsets openInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, openInsets); + when(openAnimation.getInterpolatedFraction()).thenReturn(1.0f); + callback + .getAnimationCallback() + .onProgress(openInsets, Collections.singletonList(openAnimation)); + callback.getAnimationCallback().onEnd(openAnimation); + + // Now start closing animation. + WindowInsetsAnimation closeAnimation = mock(WindowInsetsAnimation.class); + when(closeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + callback.getAnimationCallback().onPrepare(closeAnimation); + WindowInsets closedInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, closedInsets); + + // At 50% closing (fraction = 0.5f). + when(closeAnimation.getInterpolatedFraction()).thenReturn(0.5f); + WindowInsets midCloseInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET / 2)) + .build(); + callback + .getAnimationCallback() + .onProgress(midCloseInsets, Collections.singletonList(closeAnimation)); + + ArgumentCaptor<WindowInsets> midCloseCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view, org.mockito.Mockito.atLeastOnce()).onApplyWindowInsets(midCloseCaptor.capture()); + // 50% closed from 100px = 50px. + assertEquals(50, midCloseCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + // At 100% closing (fraction = 1.0f). + when(closeAnimation.getInterpolatedFraction()).thenReturn(1.0f); + WindowInsets finalCloseInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0)) + .build(); + callback + .getAnimationCallback() + .onProgress(finalCloseInsets, Collections.singletonList(closeAnimation)); + + ArgumentCaptor<WindowInsets> finalCloseCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view, org.mockito.Mockito.atLeastOnce()).onApplyWindowInsets(finalCloseCaptor.capture()); + assertEquals(0, finalCloseCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + callback.getAnimationCallback().onEnd(closeAnimation); + ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view, org.mockito.Mockito.atLeastOnce()) + .dispatchApplyWindowInsets(endInsetsCaptor.capture()); + assertEquals(0, endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + } + + /** + * Tests interrupted animations (e.g. keyboard closing gesture triggered while opening is + * in-flight). + * + * <p>If startImeBottom was read from lastWindowInsets (which stored the prior target of 100px), + * interrupting an opening animation at 50px would calculate 100 + (0 - 100) * fraction, snapping + * instantly from 50px up to 100px on frame 0 of the closing animation. This test verifies that + * tracking currentImeBottom enables smooth interpolation from the in-flight inset (50px) down to + * 0px (reaching 25px at 50% closing). + */ + @Test + public void imeAnimation_interruptedAnimation_smoothlyInterpolatesFromInFlightInset() { + View view = mock(View.class); + when(view.getWindowSystemUiVisibility()).thenReturn(0); + + ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view); + WindowInsetsAnimation openAnimation = mock(WindowInsetsAnimation.class); + when(openAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + // 1. Start opening animation towards 100px. + callback.getAnimationCallback().onPrepare(openAnimation); + WindowInsets openTargetInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, openTargetInsets); + + // Advance opening animation to 50% (fraction = 0.5f -> in-flight = 50px). + when(openAnimation.getInterpolatedFraction()).thenReturn(0.5f); + WindowInsets midOpenInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50)) + .build(); + callback + .getAnimationCallback() + .onProgress(midOpenInsets, Collections.singletonList(openAnimation)); + + ArgumentCaptor<WindowInsets> midOpenCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).onApplyWindowInsets(midOpenCaptor.capture()); + assertEquals(50, midOpenCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + // 2. Interrupt mid-flight with a closing animation towards 0px (without onEnd of the open). + WindowInsetsAnimation interruptCloseAnimation = mock(WindowInsetsAnimation.class); + when(interruptCloseAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + callback.getAnimationCallback().onPrepare(interruptCloseAnimation); + + WindowInsets closeTargetInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, closeTargetInsets); + + // At 50% fraction of the closing animation, it must smoothly interpolate + // from the in-flight starting inset (50px) down to 0px: 50 + (0 - 50) * 0.5 = 25px! + when(interruptCloseAnimation.getInterpolatedFraction()).thenReturn(0.5f); + WindowInsets midInterruptCloseInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 25)) + .build(); + callback + .getAnimationCallback() + .onProgress(midInterruptCloseInsets, Collections.singletonList(interruptCloseAnimation)); + + ArgumentCaptor<WindowInsets> midInterruptCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view, org.mockito.Mockito.times(2)).onApplyWindowInsets(midInterruptCaptor.capture()); + assertEquals(25, midInterruptCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + // At 100% closing, reaches 0px. + when(interruptCloseAnimation.getInterpolatedFraction()).thenReturn(1.0f); + WindowInsets finalInterruptCloseInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0)) + .build(); + callback + .getAnimationCallback() + .onProgress(finalInterruptCloseInsets, Collections.singletonList(interruptCloseAnimation)); + + ArgumentCaptor<WindowInsets> finalInterruptCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view, org.mockito.Mockito.times(3)).onApplyWindowInsets(finalInterruptCaptor.capture()); + assertEquals(0, finalInterruptCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + callback.getAnimationCallback().onEnd(interruptCloseAnimation); + ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).dispatchApplyWindowInsets(endInsetsCaptor.capture()); + assertEquals(0, endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + } + + /** + * Tests aborted animation lifecycles (onPrepare immediately followed by onEnd without + * onApplyWindowInsets). + * + * <p>If onPrepare sets needsSave = true and the animation is aborted before onApplyWindowInsets + * fires, onEnd must reset needsSave = false so subsequent animations are not stalled by dropped + * onProgress frames. + */ + @Test + public void imeAnimation_abortedLifecycle_resetsNeedsSaveAndAllowsSubsequentAnimations() { + View view = mock(View.class); + when(view.getWindowSystemUiVisibility()).thenReturn(0); + + ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view); + WindowInsetsAnimation abortedAnimation = mock(WindowInsetsAnimation.class); + when(abortedAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + // 1. Animation prepared but immediately ended without onApplyWindowInsets or onProgress. + callback.getAnimationCallback().onPrepare(abortedAnimation); + callback.getAnimationCallback().onEnd(abortedAnimation); + + // 2. Subsequent animation should not be blocked or stalled by stale needsSave state. + WindowInsetsAnimation nextAnimation = mock(WindowInsetsAnimation.class); + when(nextAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + callback.getAnimationCallback().onPrepare(nextAnimation); + WindowInsets targetInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, targetInsets); + + when(nextAnimation.getInterpolatedFraction()).thenReturn(1.0f); + callback + .getAnimationCallback() + .onProgress(targetInsets, Collections.singletonList(nextAnimation)); + + ArgumentCaptor<WindowInsets> progressCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).onApplyWindowInsets(progressCaptor.capture()); + assertEquals( + FINAL_IME_BOTTOM_INSET, + progressCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + callback.getAnimationCallback().onEnd(nextAnimation); + ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).dispatchApplyWindowInsets(endInsetsCaptor.capture()); + assertEquals( + FINAL_IME_BOTTOM_INSET, + endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + } + + /** + * Tests concurrent animation of IME and other system bars (status bars / navigation bars). + * + * <p>If WindowInsets.Builder used a stale lastWindowInsets snapshot, freezing non-IME system bar + * animations during IME transitions. This test verifies that non-IME insets from the current + * frame's insets object are preserved. + */ + @Test + public void imeAnimation_concurrentSystemBarAnimations_preservesNonImeInsets() { + View view = mock(View.class); + when(view.getWindowSystemUiVisibility()).thenReturn(0); + + ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view); + WindowInsetsAnimation imeAnimation = mock(WindowInsetsAnimation.class); + when(imeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + callback.getAnimationCallback().onPrepare(imeAnimation); + WindowInsets initialInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .setInsets(WindowInsets.Type.statusBars(), Insets.of(0, 50, 0, 0)) + .setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, initialInsets); + + // During onProgress, status bar insets change concurrently from 50px to 60px. + when(imeAnimation.getInterpolatedFraction()).thenReturn(0.5f); + WindowInsets progressInsetsWithChangedStatusBar = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50)) + .setInsets(WindowInsets.Type.statusBars(), Insets.of(0, 60, 0, 0)) + .setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40)) + .build(); + callback + .getAnimationCallback() + .onProgress(progressInsetsWithChangedStatusBar, Collections.singletonList(imeAnimation)); + + ArgumentCaptor<WindowInsets> progressCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).onApplyWindowInsets(progressCaptor.capture()); + + // IME insets interpolated to 50px. + assertEquals(50, progressCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + // Non-IME status bar insets from the current frame (60px) are preserved and not locked to 50px. + assertEquals(60, progressCaptor.getValue().getInsets(WindowInsets.Type.statusBars()).top); + } + + /** + * Tests arrival of non-animated window insets while !animating. + * + * <p>When window insets arrive without an animation (e.g. initial layout, orientation change, + * split screen, or hardware keyboard toggle), onApplyWindowInsets must update currentImeBottom + * and lastWindowInsets. If not updated, currentImeBottom remains 0, causing any subsequent + * animated dismissal to start from 0 and immediately collapse on frame 1. + */ + @Test + public void + imeAnimation_nonAnimatedInsetsArrival_synchronizesCurrentImeBottomAndAllowsSubsequentAnimatedDismissal() { + View view = mock(View.class); + when(view.getWindowSystemUiVisibility()).thenReturn(0); + + ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view); + + // 1. Non-animated insets arrive (e.g. keyboard shown instantly or orientation changed). + WindowInsets nonAnimatedInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, nonAnimatedInsets); + + // 2. An animated dismissal is initiated. + WindowInsetsAnimation closeAnimation = mock(WindowInsetsAnimation.class); + when(closeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + callback.getAnimationCallback().onPrepare(closeAnimation); + + WindowInsets closeTargetInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, closeTargetInsets); + + // At 50% fraction, animated insets must smoothly interpolate from 100px down to 0px (reaching + // 50px). + // Without synchronizing currentImeBottom, startImeBottom would have been 0px and output 0px. + when(closeAnimation.getInterpolatedFraction()).thenReturn(0.5f); + WindowInsets midCloseInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50)) + .build(); + callback + .getAnimationCallback() + .onProgress(midCloseInsets, Collections.singletonList(closeAnimation)); + + ArgumentCaptor<WindowInsets> midCloseCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view, org.mockito.Mockito.atLeastOnce()).onApplyWindowInsets(midCloseCaptor.capture()); + assertEquals(50, midCloseCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + // Dismissal reaches 0px at fraction 1.0f. + when(closeAnimation.getInterpolatedFraction()).thenReturn(1.0f); + WindowInsets terminalCloseInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0)) + .build(); + callback + .getAnimationCallback() + .onProgress(terminalCloseInsets, Collections.singletonList(closeAnimation)); + + callback.getAnimationCallback().onEnd(closeAnimation); + ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view, org.mockito.Mockito.atLeastOnce()) + .dispatchApplyWindowInsets(endInsetsCaptor.capture()); + assertEquals(0, endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + } + + /** + * Tests non-IME animation preparation (e.g. status bar / caption bar animations). + * + * <p>onPrepare for non-IME animations must not set needsSave = true, ensuring that running IME + * animations are not stalled or interrupted. + */ + @Test + public void imeAnimation_nonImeAnimationPrepare_doesNotSetNeedsSaveOrStallRunningImeAnimations() { + View view = mock(View.class); + when(view.getWindowSystemUiVisibility()).thenReturn(0); + + ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view); + + // 1. IME animation prepared and started. + WindowInsetsAnimation imeAnimation = mock(WindowInsetsAnimation.class); + when(imeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + callback.getAnimationCallback().onPrepare(imeAnimation); + + WindowInsets imeTargetInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, imeTargetInsets); + + // 2. A non-IME animation (e.g. status bar transition) prepares during IME animation, + // followed by target insets delivery per Android WindowInsetsAnimation contract. + WindowInsetsAnimation statusBarAnimation = mock(WindowInsetsAnimation.class); + when(statusBarAnimation.getTypeMask()).thenReturn(WindowInsets.Type.statusBars()); + callback.getAnimationCallback().onPrepare(statusBarAnimation); + callback.getInsetsListener().onApplyWindowInsets(view, imeTargetInsets); + + // 3. onProgress for IME animation smoothly continues after target insets capture. + when(imeAnimation.getInterpolatedFraction()).thenReturn(0.5f); + WindowInsets midProgressInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50)) + .build(); + callback + .getAnimationCallback() + .onProgress(midProgressInsets, Collections.singletonList(imeAnimation)); + + ArgumentCaptor<WindowInsets> progressCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).onApplyWindowInsets(progressCaptor.capture()); + assertEquals(50, progressCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + } + + /** + * Tests cleanup of callbacks and internal state in remove(). + * + * <p>Rationale / Added for: Verifies that unhooking listeners from the view resets all internal + * state flags (animating, needsSave, startImeBottom, currentImeBottom) to prevent stale state on + * re-install. + */ + @Test + public void imeAnimation_remove_cleansUpAllState() { + View view = mock(View.class); + ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view); + callback.install(); + verify(view).setWindowInsetsAnimationCallback(callback.getAnimationCallback()); + verify(view).setOnApplyWindowInsetsListener(callback.getInsetsListener()); + + callback.remove(); + verify(view).setWindowInsetsAnimationCallback(null); + verify(view).setOnApplyWindowInsetsListener(null); + assertEquals( + ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.IDLE, + callback.getStateMachine().getState()); + assertEquals(0, callback.getStateMachine().getStartImeBottom()); + assertEquals(0, callback.getStateMachine().getCurrentImeBottom()); + } + + /** + * Tests discrete state transitions of AnimationStateMachine across a full animation lifecycle. + * + * <p>Rationale / Added for: Validates the encapsulated State Machine helper class transitions + * explicitly: IDLE -> PREPARED -> ANIMATING -> IDLE. + */ + @Test + public void stateMachine_fullLifecycleTransitions() { + View view = mock(View.class); + ImeSyncDeferringInsetsCallback.AnimationStateMachine stateMachine = + new ImeSyncDeferringInsetsCallback.AnimationStateMachine(WindowInsets.Type.ime()); + + // 1. Initial State: IDLE + assertEquals( + ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.IDLE, stateMachine.getState()); + + // 2. onPrepare -> PREPARED + WindowInsetsAnimation imeAnimation = mock(WindowInsetsAnimation.class); + when(imeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + stateMachine.onPrepare(imeAnimation); + assertEquals( + ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.PREPARED, + stateMachine.getState()); + + // 3. onApplyWindowInsets -> ANIMATING + WindowInsets targetInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .build(); + WindowInsets consumedResult = stateMachine.onApplyWindowInsets(view, targetInsets); + assertEquals(WindowInsets.CONSUMED, consumedResult); + assertEquals( + ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.ANIMATING, + stateMachine.getState()); + + // 4. onProgress -> remains ANIMATING + when(imeAnimation.getInterpolatedFraction()).thenReturn(0.5f); + WindowInsets progressInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50)) + .build(); + stateMachine.onProgress(view, progressInsets, Collections.singletonList(imeAnimation)); + assertEquals( + ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.ANIMATING, + stateMachine.getState()); + assertEquals(50, stateMachine.getCurrentImeBottom()); + + // 5. onEnd -> settles and transitions to IDLE + stateMachine.onEnd(view, imeAnimation); + assertEquals( + ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.IDLE, stateMachine.getState()); + assertEquals(100, stateMachine.getCurrentImeBottom()); + } + + /** + * Tests discrete state transition when an animation is aborted before onApplyWindowInsets. + * + * <p>Rationale / Added for: Verifies that onEnd while in PREPARED state resets cleanly to IDLE + * without applying stale insets. + */ + @Test + public void stateMachine_abortedLifecycle_resetsToIdle() { + View view = mock(View.class); + ImeSyncDeferringInsetsCallback.AnimationStateMachine stateMachine = + new ImeSyncDeferringInsetsCallback.AnimationStateMachine(WindowInsets.Type.ime()); + + WindowInsetsAnimation imeAnimation = mock(WindowInsetsAnimation.class); + when(imeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + stateMachine.onPrepare(imeAnimation); + assertEquals( + ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.PREPARED, + stateMachine.getState()); + + // Aborted: onEnd called immediately + stateMachine.onEnd(view, imeAnimation); + assertEquals( + ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.IDLE, stateMachine.getState()); + } + + /** + * Tests that onEnd transitions to IDLE before invoking view.dispatchApplyWindowInsets. + * + * <p>In a real Android View hierarchy, view.dispatchApplyWindowInsets immediately forwards to the + * view's OnApplyWindowInsetsListener (the callback's insets listener). If state is still + * ANIMATING, onApplyWindowInsets returns CONSUMED and drops the final insets. Transitioning to + * IDLE before dispatching ensures the settled insets are delivered to view.onApplyWindowInsets. + */ + @Test + public void stateMachine_onEnd_transitionsToIdleBeforeDispatchingSoViewReceivesSettledInsets() { + View view = mock(View.class); + ImeSyncDeferringInsetsCallback.AnimationStateMachine stateMachine = + new ImeSyncDeferringInsetsCallback.AnimationStateMachine(WindowInsets.Type.ime()); + + WindowInsetsAnimation imeAnimation = mock(WindowInsetsAnimation.class); + when(imeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + // 1. Prepare and capture target insets + stateMachine.onPrepare(imeAnimation); + WindowInsets targetInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .build(); + stateMachine.onApplyWindowInsets(view, targetInsets); + assertEquals( + ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.ANIMATING, + stateMachine.getState()); + + // 2. Simulate Android framework forwarding: dispatchApplyWindowInsets invokes + // onApplyWindowInsets + org.mockito.Mockito.doAnswer( + invocation -> { + WindowInsets insets = invocation.getArgument(0); + return stateMachine.onApplyWindowInsets(view, insets); + }) + .when(view) + .dispatchApplyWindowInsets(org.mockito.ArgumentMatchers.any(WindowInsets.class)); + + // 3. onEnd is called + stateMachine.onEnd(view, imeAnimation); + + // State is IDLE, and view.onApplyWindowInsets was invoked with targetInsets + assertEquals( + ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.IDLE, stateMachine.getState()); + ArgumentCaptor<WindowInsets> captor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view, org.mockito.Mockito.atLeastOnce()).onApplyWindowInsets(captor.capture()); + assertEquals( + FINAL_IME_BOTTOM_INSET, captor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + } + + /** + * Tests backgrounding and resuming the app with a cancelled hide animation (issue #191156). + * + * <p>Verifies that when an app resumes from the background after the keyboard is dismissed, and + * Android cancels an internal hide animation (PHASE_CLIENT_ANIMATION_CANCEL), stale keyboard + * insets from the previous open state are not restored. + */ + @Test + public void backgroundResume_cancelledHideAnimation_doesNotRestoreStaleKeyboardInsets() { + View view = mock(View.class); + ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view); + + WindowInsetsAnimation openAnimation = mock(WindowInsetsAnimation.class); + when(openAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + // 1. Keyboard opens to full height (e.g. 100px). + callback.getAnimationCallback().onPrepare(openAnimation); + WindowInsets openInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, openInsets); + when(openAnimation.getInterpolatedFraction()).thenReturn(1.0f); + callback + .getAnimationCallback() + .onProgress(openInsets, Collections.singletonList(openAnimation)); + callback.getAnimationCallback().onEnd(openAnimation); + + // Verify keyboard settled at 100px. + ArgumentCaptor<WindowInsets> openCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view).dispatchApplyWindowInsets(openCaptor.capture()); + assertEquals( + FINAL_IME_BOTTOM_INSET, openCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + // 2. App is backgrounded and resumes: OS delivers non-animated insets with ime = 0. + WindowInsets resumeZeroInsets = + new WindowInsets.Builder() + .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0)) + .build(); + callback.getInsetsListener().onApplyWindowInsets(view, resumeZeroInsets); + + ArgumentCaptor<WindowInsets> resumeCaptor = ArgumentCaptor.forClass(WindowInsets.class); + verify(view, org.mockito.Mockito.atLeastOnce()).onApplyWindowInsets(resumeCaptor.capture()); + assertEquals(0, resumeCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom); + + // 3. Android InsetsController initiates hide(ime()) animation, which gets cancelled + // (onPrepare called, followed immediately by onEnd without onApplyWindowInsets or onProgress). + WindowInsetsAnimation cancelledHideAnimation = mock(WindowInsetsAnimation.class); + when(cancelledHideAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + callback.getAnimationCallback().onPrepare(cancelledHideAnimation); + callback.getAnimationCallback().onEnd(cancelledHideAnimation); + + // 4. Assert that view.dispatchApplyWindowInsets was NOT called again with the stale 100px + // insets. + // The only dispatchApplyWindowInsets call should have been the initial open from step 1. + verify(view, org.mockito.Mockito.times(1)) + .dispatchApplyWindowInsets(org.mockito.ArgumentMatchers.any(WindowInsets.class)); + } +}
diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java index fd3b63c..83fdecf 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java
@@ -2695,25 +2695,26 @@ verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom); - // Progress the animation and ensure that the navigation bar insets have been subtracted - // from the IME insets + // Progress the animation and ensure that the IME insets smoothly interpolate + // to the settled target IME insets. + when(animation.getInterpolatedFraction()).thenReturn(0.25f); builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 25)); builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40)); imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList); verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); - assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom); + assertEquals(25, viewportMetricsCaptor.getValue().viewInsetBottom); + when(animation.getInterpolatedFraction()).thenReturn(0.50f); builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50)); builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40)); imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList); verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); - assertEquals(10, viewportMetricsCaptor.getValue().viewInsetBottom); + assertEquals(50, viewportMetricsCaptor.getValue().viewInsetBottom); // End the animation and ensure that the bottom insets match the lastWindowInsets that - // we set - // during onPrepare + // we set during onPrepare. imeSyncCallback.getAnimationCallback().onEnd(animation); verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); @@ -2792,6 +2793,7 @@ // Progress the animation and ensure that the navigation bar insets have not been // subtracted from the IME insets + when(animation.getInterpolatedFraction()).thenReturn(0.25f); builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 25)); builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40)); imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList); @@ -2799,6 +2801,7 @@ verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); assertEquals(25, viewportMetricsCaptor.getValue().viewInsetBottom); + when(animation.getInterpolatedFraction()).thenReturn(0.50f); builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50)); builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40)); imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList); @@ -2889,6 +2892,7 @@ // Progress the animation and ensure that the navigation bar insets have not been // subtracted from the IME insets + when(animation.getInterpolatedFraction()).thenReturn(0.25f); builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 25)); builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40)); imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList); @@ -2896,6 +2900,7 @@ verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); assertEquals(25, viewportMetricsCaptor.getValue().viewInsetBottom); + when(animation.getInterpolatedFraction()).thenReturn(0.50f); builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50)); builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40)); imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList);
diff --git a/packages/flutter/test/widgets/ime_insets_animation_layout_test.dart b/packages/flutter/test/widgets/ime_insets_animation_layout_test.dart new file mode 100644 index 0000000..e13ae34 --- /dev/null +++ b/packages/flutter/test/widgets/ime_insets_animation_layout_test.dart
@@ -0,0 +1,158 @@ +// 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/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Test suite validating visual layout continuity and absence of terminal +/// insets jumps during soft keyboard animation in edgeToEdge mode. +/// +/// What it tests: +/// Compares layout and widget positions at the terminal progress frame (fraction = 1.0) +/// versus the settled state (onEnd) for: +/// 1. The baseline buggy engine behavior (where onProgress subtracted navigation bar height, +/// causing a 24dp jump upon onEnd dispatch). +/// 2. The fixed engine behavior (where target-inset interpolation ensures 0dp terminal jump). +/// +/// Why it was added: +/// Regression test for flutter/flutter#190974 ("viewInsets.bottom jumps by navigation bar +/// height at the end of IME open animation in edgeToEdge mode on Android API 30-34"). +void main() { + testWidgets('IME insets animation comparison: before vs after fix visual continuity', ( + WidgetTester tester, + ) async { + // Exact reproduction UI from issue #190974: + // A view with bottom padding driven by MediaQuery.viewInsetsOf(context).bottom. + // In edgeToEdge mode on API 34, viewPadding.bottom is 24dp (gesture bar). + Widget buildTestApp(double bottomInset) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData( + size: const Size(400, 800), + padding: const EdgeInsets.only(bottom: 24), + viewPadding: const EdgeInsets.only(bottom: 24), + viewInsets: EdgeInsets.only(bottom: bottomInset), + ), + child: ColoredBox( + color: const Color(0xFFECEFF1), + child: Builder( + builder: (BuildContext context) { + final double insetsBottom = MediaQuery.viewInsetsOf(context).bottom; + final double paddingBottom = MediaQuery.viewPaddingOf(context).bottom; + return Padding( + padding: EdgeInsets.only(bottom: insetsBottom), + child: Column( + children: <Widget>[ + const SizedBox(height: 40), + Text( + 'IME Insets: ${insetsBottom.toStringAsFixed(1)} dp | Padding: ${paddingBottom.toStringAsFixed(1)} dp', + key: const ValueKey<String>('metrics_label'), + textDirection: TextDirection.ltr, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const Spacer(), + // Distinct indicator strip at the bottom of the content area + const ColoredBox( + key: ValueKey<String>('indicator_bar'), + color: Color(0xFFFF0000), + child: SizedBox(height: 8, width: double.infinity), + ), + const ColoredBox( + key: ValueKey<String>('input_box'), + color: Color(0xFFCFD8DC), + child: SizedBox( + height: 56, + width: double.infinity, + child: Center( + child: Text( + 'Input Field (pinned above keyboard)', + textDirection: TextDirection.ltr, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ), + ); + } + + const targetSettledIme = 336.0; // Settled keyboard height on API 34 + const navBarHeight = 24.0; // Navigation bar height on API 34 + + // ------------------------------------------------------------------------- + // 1. BEFORE FIX: Simulating the bug from #190974 + // ------------------------------------------------------------------------- + // During animation, excludedInsets subtracted navBarHeight (24dp). + // Terminal frame of onProgress reported 336 - 24 = 312 dp. + // Immediately onEnd dispatched 336 dp -> 24 dp jump. + const double beforeTerminalProgress = targetSettledIme - navBarHeight; // 312.0 + const beforeSettled = targetSettledIme; // 336.0 + + // Render Before Terminal Frame (312dp) + await tester.pumpWidget(buildTestApp(beforeTerminalProgress)); + await tester.pumpAndSettle(); + final Rect beforeTerminalRect = tester.getRect( + find.byKey(const ValueKey<String>('indicator_bar')), + ); + + // Render Before Settled Frame (336dp) + await tester.pumpWidget(buildTestApp(beforeSettled)); + await tester.pumpAndSettle(); + final Rect beforeSettledRect = tester.getRect( + find.byKey(const ValueKey<String>('indicator_bar')), + ); + + // The jump before the fix: + final double beforeJump = beforeTerminalRect.bottom - beforeSettledRect.bottom; + expect( + beforeJump, + equals(navBarHeight), + reason: 'Before fix, indicator jumps by navBarHeight (24dp) at onEnd', + ); + + // ------------------------------------------------------------------------- + // 2. AFTER FIX (fix-ime-insets-animation-jump branch) + // ------------------------------------------------------------------------- + // In the fixed branch, animated insets are interpolated to targetSettledIme (336dp). + // Terminal progress frame (fraction = 1.0) reports 336.0 dp. + // onEnd frame dispatches 336.0 dp. + const afterTerminalProgress = targetSettledIme; // 336.0 + const afterSettled = targetSettledIme; // 336.0 + + // Render After Terminal Frame (336dp) + await tester.pumpWidget(buildTestApp(afterTerminalProgress)); + await tester.pumpAndSettle(); + final Rect afterTerminalRect = tester.getRect( + find.byKey(const ValueKey<String>('indicator_bar')), + ); + + // Render After Settled Frame (336dp) + await tester.pumpWidget(buildTestApp(afterSettled)); + await tester.pumpAndSettle(); + final Rect afterSettledRect = tester.getRect( + find.byKey(const ValueKey<String>('indicator_bar')), + ); + + // The jump after the fix: + final double afterJump = afterTerminalRect.bottom - afterSettledRect.bottom; + expect( + afterJump, + equals(0.0), + reason: 'After fix, indicator position is perfectly continuous with 0 jump', + ); + + // ------------------------------------------------------------------------- + // 3. Compare visual position matching + // ------------------------------------------------------------------------- + expect(afterTerminalRect, equals(afterSettledRect)); + expect(afterSettledRect, equals(beforeSettledRect)); + expect(beforeTerminalRect, isNot(equals(afterTerminalRect))); + }); +}