mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
[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:
parent
7e324f826a
commit
cb8854c7d6
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user