diff --git a/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java b/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java index 0a6f0ef3f64db..fc9a72e494170 100644 --- a/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java +++ b/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java @@ -47,9 +47,7 @@ @SuppressLint({"NewApi", "Override"}) @Keep class ImeSyncDeferringInsetsCallback { - private int overlayInsetTypes; - private int deferredInsetTypes; - + private final int deferredInsetTypes = WindowInsets.Type.ime(); private View view; private WindowInsets lastWindowInsets; private AnimationCallback animationCallback; @@ -67,10 +65,7 @@ class ImeSyncDeferringInsetsCallback { // initial WindowInset. private boolean needsSave = false; - ImeSyncDeferringInsetsCallback( - @NonNull View view, int overlayInsetTypes, int deferredInsetTypes) { - this.overlayInsetTypes = overlayInsetTypes; - this.deferredInsetTypes = deferredInsetTypes; + ImeSyncDeferringInsetsCallback(@NonNull View view) { this.view = view; this.animationCallback = new AnimationCallback(); this.insetsListener = new InsetsListener(); @@ -131,24 +126,24 @@ public WindowInsets onProgress( if (!matching) { return insets; } + + // 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 ((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); - // Overlay the ime-only insets with the full insets. - // - // The IME insets passed in by onProgress assumes that the entire animation - // occurs above any present navigation and status bars. This causes the - // IME inset to be too large for the animation. To remedy this, we merge the - // IME inset with other insets present via a subtract + reLu, which causes the - // IME inset to be overlaid with any bars present. Insets newImeInsets = Insets.of( - 0, - 0, - 0, - Math.max( - insets.getInsets(deferredInsetTypes).bottom - - insets.getInsets(overlayInsetTypes).bottom, - 0)); + 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 diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 10cbc1b65cb61..f38a48f53a0d3 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -15,7 +15,6 @@ import android.view.KeyEvent; import android.view.View; import android.view.ViewStructure; -import android.view.WindowInsets; import android.view.autofill.AutofillId; import android.view.autofill.AutofillManager; import android.view.autofill.AutofillValue; @@ -80,19 +79,7 @@ public TextInputPlugin( // the Flutter view to grow and shrink to accommodate Android // controlled keyboard animations. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - int mask = 0; - if ((View.SYSTEM_UI_FLAG_HIDE_NAVIGATION & mView.getWindowSystemUiVisibility()) == 0) { - mask = mask | WindowInsets.Type.navigationBars(); - } - if ((View.SYSTEM_UI_FLAG_FULLSCREEN & mView.getWindowSystemUiVisibility()) == 0) { - mask = mask | WindowInsets.Type.statusBars(); - } - imeSyncCallback = - new ImeSyncDeferringInsetsCallback( - view, - mask, // Overlay, insets that should be merged with the deferred insets - WindowInsets.Type.ime() // Deferred, insets that will animate - ); + imeSyncCallback = new ImeSyncDeferringInsetsCallback(view); imeSyncCallback.install(); } diff --git a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java index 462b6ec2ff808..b45fd05fa4805 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -2029,8 +2029,10 @@ public void sendAppPrivateCommand_hasData() throws JSONException { @Test @TargetApi(30) @Config(sdk = 30) - public void ime_windowInsetsSync() { - FlutterView testView = new FlutterView(Robolectric.setupActivity(Activity.class)); + public void ime_windowInsetsSync_notLaidOutBehindNavigation_excludesNavigationBars() { + FlutterView testView = spy(new FlutterView(Robolectric.setupActivity(Activity.class))); + when(testView.getWindowSystemUiVisibility()).thenReturn(View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class)); TextInputPlugin textInputPlugin = new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); @@ -2046,76 +2048,136 @@ public void ime_windowInsetsSync() { List animationList = new ArrayList(); animationList.add(animation); + ArgumentCaptor viewportMetricsCaptor = + ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class); + WindowInsets.Builder builder = new WindowInsets.Builder(); - WindowInsets noneInsets = builder.build(); - // imeInsets0, 1, and 2 contain unique IME bottom insets, and are used - // to distinguish which insets were sent at each stage. + // Set the initial insets and verify that they were set and the bottom view inset is correct + imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, builder.build()); + + verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); + assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom); + + // Call onPrepare and set the lastWindowInsets - these should be stored for the end of the + // animation instead of being applied immediately + imeSyncCallback.getAnimationCallback().onPrepare(animation); builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 100)); - builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 40)); - WindowInsets imeInsets0 = builder.build(); + builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 0)); + imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, builder.build()); + + verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); + assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom); + + // Call onStart and apply new insets - these should be ignored completely + imeSyncCallback.getAnimationCallback().onStart(animation, null); + builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50)); + builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40)); + imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, builder.build()); - builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 30)); - builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 40)); - WindowInsets imeInsets1 = builder.build(); + 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 + 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); builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50)); - builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 40)); - WindowInsets imeInsets2 = builder.build(); + 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); + + // End the animation and ensure that the bottom insets match the lastWindowInsets that we set + // during onPrepare + imeSyncCallback.getAnimationCallback().onEnd(animation); + + verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); + assertEquals(100, viewportMetricsCaptor.getValue().viewInsetBottom); + } + + @Test + @TargetApi(30) + @Config(sdk = 30) + public void ime_windowInsetsSync_laidOutBehindNavigation_includesNavigationBars() { + FlutterView testView = spy(new FlutterView(Robolectric.setupActivity(Activity.class))); + when(testView.getWindowSystemUiVisibility()) + .thenReturn( + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); + + TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class)); + TextInputPlugin textInputPlugin = + new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); + ImeSyncDeferringInsetsCallback imeSyncCallback = textInputPlugin.getImeSyncCallback(); + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni)); + when(flutterEngine.getRenderer()).thenReturn(flutterRenderer); + testView.attachToFlutterEngine(flutterEngine); - builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 200)); - builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 0)); - WindowInsets deferredInsets = builder.build(); + WindowInsetsAnimation animation = mock(WindowInsetsAnimation.class); + when(animation.getTypeMask()).thenReturn(WindowInsets.Type.ime()); + + List animationList = new ArrayList(); + animationList.add(animation); ArgumentCaptor viewportMetricsCaptor = ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class); - imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, deferredInsets); - imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, noneInsets); + WindowInsets.Builder builder = new WindowInsets.Builder(); + + // Set the initial insets and verify that they were set and the bottom view inset is correct + imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, builder.build()); verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); - assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingBottom); - assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingTop); assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom); - assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop); + // Call onPrepare and set the lastWindowInsets - these should be stored for the end of the + // animation instead of being applied immediately imeSyncCallback.getAnimationCallback().onPrepare(animation); - imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, deferredInsets); + builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 100)); + builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 0)); + imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, builder.build()); + + verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); + assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom); + + // Call onStart and apply new insets - these should be ignored completely imeSyncCallback.getAnimationCallback().onStart(animation, null); - // Only the final state call is saved, extra calls are passed on. - imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, imeInsets2); + builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50)); + builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40)); + imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, builder.build()); verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); - // No change, as deferredInset is stored to be passed in onEnd() - assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingBottom); - assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingTop); assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom); - assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop); - imeSyncCallback.getAnimationCallback().onProgress(imeInsets0, animationList); + // Progress the animation and ensure that the navigation bar insets have not been + // subtracted from the IME insets + 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().viewPaddingBottom); - assertEquals(10, viewportMetricsCaptor.getValue().viewPaddingTop); - assertEquals(60, viewportMetricsCaptor.getValue().viewInsetBottom); - assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop); + assertEquals(25, viewportMetricsCaptor.getValue().viewInsetBottom); - imeSyncCallback.getAnimationCallback().onProgress(imeInsets1, animationList); + 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(0, viewportMetricsCaptor.getValue().viewPaddingBottom); - assertEquals(10, viewportMetricsCaptor.getValue().viewPaddingTop); - assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom); // Cannot be negative - assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop); + assertEquals(50, viewportMetricsCaptor.getValue().viewInsetBottom); + // End the animation and ensure that the bottom insets match the lastWindowInsets that we set + // during onPrepare imeSyncCallback.getAnimationCallback().onEnd(animation); verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture()); - // Values should be of deferredInsets, not imeInsets2 - assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingBottom); - assertEquals(10, viewportMetricsCaptor.getValue().viewPaddingTop); - assertEquals(200, viewportMetricsCaptor.getValue().viewInsetBottom); - assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop); + assertEquals(100, viewportMetricsCaptor.getValue().viewInsetBottom); } interface EventHandler {