[Android] Fix incorrect viewInsets during keyboard animation with EdgeToEdge (flutter/engine#39391)

Currently during the keyboard animation, the navigation bar insets are subtracted from the keyboard insets. This is correct when the app isn't laid out behind the navigation bar, but results in incorrect viewInsets when the app's running in edge-to-edge or fullscreen.

This change checks if the app is being laid out behind the navigation bar and adjusts the bottom insets accordingly during the keyboard animation.

Fixes https://github.com/flutter/flutter/issues/89914

Tested on Android 13 (Pixel 7) using the code sample here: https://github.com/flutter/flutter/issues/109623

### Before

https://user-images.githubusercontent.com/20386860/216786596-24c764b1-a71c-42cf-97a2-3ba10b717819.mp4

### After

https://user-images.githubusercontent.com/20386860/216786591-155ec6a6-b3c5-41e0-a45f-169861077ce2.mp4
This commit is contained in:
Jonathan Cole 2023-05-08 20:57:47 +01:00 committed by GitHub
parent 7e324f826a
commit cb8854c7d6
3 changed files with 122 additions and 78 deletions

View File

@ -51,9 +51,7 @@ import java.util.List;
@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;
@ -72,10 +70,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();
@ -160,24 +155,24 @@ class ImeSyncDeferringInsetsCallback {
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

View File

@ -15,7 +15,6 @@ import android.util.SparseArray;
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 class TextInputPlugin implements ListenableEditingState.EditingStateWatch
// 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();
// When the IME is hidden, we need to notify the framework that close connection.

View File

@ -2029,8 +2029,10 @@ public class TextInputPluginTest {
@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 class TextInputPluginTest {
List<WindowInsetsAnimation> animationList = new ArrayList();
animationList.add(animation);
ArgumentCaptor<FlutterRenderer.ViewportMetrics> 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());
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);
// 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());
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);
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();
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);
WindowInsetsAnimation animation = mock(WindowInsetsAnimation.class);
when(animation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
List<WindowInsetsAnimation> animationList = new ArrayList();
animationList.add(animation);
ArgumentCaptor<FlutterRenderer.ViewportMetrics> 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);
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, 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());
// 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);
// 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());
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(0, viewportMetricsCaptor.getValue().viewInsetBottom);
imeSyncCallback.getAnimationCallback().onProgress(imeInsets1, 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(0, viewportMetricsCaptor.getValue().viewInsetBottom); // Cannot be negative
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop);
assertEquals(25, viewportMetricsCaptor.getValue().viewInsetBottom);
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(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);
}
@Test