Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit c94be7a

Browse files
[Android] Fix incorrect viewInsets during keyboard animation with EdgeToEdge (#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 flutter/flutter#89914 Tested on Android 13 (Pixel 7) using the code sample here: flutter/flutter#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
1 parent edf0f90 commit c94be7a

File tree

3 files changed

+121
-77
lines changed

3 files changed

+121
-77
lines changed

shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,7 @@
5151
@SuppressLint({"NewApi", "Override"})
5252
@Keep
5353
class ImeSyncDeferringInsetsCallback {
54-
private int overlayInsetTypes;
55-
private int deferredInsetTypes;
56-
54+
private final int deferredInsetTypes = WindowInsets.Type.ime();
5755
private View view;
5856
private WindowInsets lastWindowInsets;
5957
private AnimationCallback animationCallback;
@@ -72,10 +70,7 @@ class ImeSyncDeferringInsetsCallback {
7270
// initial WindowInset.
7371
private boolean needsSave = false;
7472

75-
ImeSyncDeferringInsetsCallback(
76-
@NonNull View view, int overlayInsetTypes, int deferredInsetTypes) {
77-
this.overlayInsetTypes = overlayInsetTypes;
78-
this.deferredInsetTypes = deferredInsetTypes;
73+
ImeSyncDeferringInsetsCallback(@NonNull View view) {
7974
this.view = view;
8075
this.animationCallback = new AnimationCallback();
8176
this.insetsListener = new InsetsListener();
@@ -160,24 +155,24 @@ public WindowInsets onProgress(
160155
if (!matching) {
161156
return insets;
162157
}
158+
159+
// The IME insets include the height of the navigation bar. If the app isn't laid out behind
160+
// the navigation bar, this causes the IME insets to be too large during the animation.
161+
// To fix this, we subtract the navigationBars bottom inset if the system UI flags for laying
162+
// out behind the navigation bar aren't present.
163+
int excludedInsets = 0;
164+
int systemUiFlags = view.getWindowSystemUiVisibility();
165+
if ((systemUiFlags & View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION) == 0
166+
&& (systemUiFlags & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0) {
167+
excludedInsets = insets.getInsets(WindowInsets.Type.navigationBars()).bottom;
168+
}
169+
163170
WindowInsets.Builder builder = new WindowInsets.Builder(lastWindowInsets);
164-
// Overlay the ime-only insets with the full insets.
165-
//
166-
// The IME insets passed in by onProgress assumes that the entire animation
167-
// occurs above any present navigation and status bars. This causes the
168-
// IME inset to be too large for the animation. To remedy this, we merge the
169-
// IME inset with other insets present via a subtract + reLu, which causes the
170-
// IME inset to be overlaid with any bars present.
171171
Insets newImeInsets =
172172
Insets.of(
173-
0,
174-
0,
175-
0,
176-
Math.max(
177-
insets.getInsets(deferredInsetTypes).bottom
178-
- insets.getInsets(overlayInsetTypes).bottom,
179-
0));
173+
0, 0, 0, Math.max(insets.getInsets(deferredInsetTypes).bottom - excludedInsets, 0));
180174
builder.setInsets(deferredInsetTypes, newImeInsets);
175+
181176
// Directly call onApplyWindowInsets of the view as we do not want to pass through
182177
// the onApplyWindowInsets defined in this class, which would consume the insets
183178
// as if they were a non-animation inset change and cache it for re-dispatch in

shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import android.view.KeyEvent;
1616
import android.view.View;
1717
import android.view.ViewStructure;
18-
import android.view.WindowInsets;
1918
import android.view.autofill.AutofillId;
2019
import android.view.autofill.AutofillManager;
2120
import android.view.autofill.AutofillValue;
@@ -80,19 +79,7 @@ public TextInputPlugin(
8079
// the Flutter view to grow and shrink to accommodate Android
8180
// controlled keyboard animations.
8281
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
83-
int mask = 0;
84-
if ((View.SYSTEM_UI_FLAG_HIDE_NAVIGATION & mView.getWindowSystemUiVisibility()) == 0) {
85-
mask = mask | WindowInsets.Type.navigationBars();
86-
}
87-
if ((View.SYSTEM_UI_FLAG_FULLSCREEN & mView.getWindowSystemUiVisibility()) == 0) {
88-
mask = mask | WindowInsets.Type.statusBars();
89-
}
90-
imeSyncCallback =
91-
new ImeSyncDeferringInsetsCallback(
92-
view,
93-
mask, // Overlay, insets that should be merged with the deferred insets
94-
WindowInsets.Type.ime() // Deferred, insets that will animate
95-
);
82+
imeSyncCallback = new ImeSyncDeferringInsetsCallback(view);
9683
imeSyncCallback.install();
9784

9885
// When the IME is hidden, we need to notify the framework that close connection.

shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java

Lines changed: 104 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2029,8 +2029,10 @@ public void sendAppPrivateCommand_hasData() throws JSONException {
20292029
@Test
20302030
@TargetApi(30)
20312031
@Config(sdk = 30)
2032-
public void ime_windowInsetsSync() {
2033-
FlutterView testView = new FlutterView(Robolectric.setupActivity(Activity.class));
2032+
public void ime_windowInsetsSync_notLaidOutBehindNavigation_excludesNavigationBars() {
2033+
FlutterView testView = spy(new FlutterView(Robolectric.setupActivity(Activity.class)));
2034+
when(testView.getWindowSystemUiVisibility()).thenReturn(View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
2035+
20342036
TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class));
20352037
TextInputPlugin textInputPlugin =
20362038
new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class));
@@ -2046,76 +2048,136 @@ public void ime_windowInsetsSync() {
20462048
List<WindowInsetsAnimation> animationList = new ArrayList();
20472049
animationList.add(animation);
20482050

2051+
ArgumentCaptor<FlutterRenderer.ViewportMetrics> viewportMetricsCaptor =
2052+
ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class);
2053+
20492054
WindowInsets.Builder builder = new WindowInsets.Builder();
2050-
WindowInsets noneInsets = builder.build();
20512055

2052-
// imeInsets0, 1, and 2 contain unique IME bottom insets, and are used
2053-
// to distinguish which insets were sent at each stage.
2056+
// Set the initial insets and verify that they were set and the bottom view inset is correct
2057+
imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, builder.build());
2058+
2059+
verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
2060+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom);
2061+
2062+
// Call onPrepare and set the lastWindowInsets - these should be stored for the end of the
2063+
// animation instead of being applied immediately
2064+
imeSyncCallback.getAnimationCallback().onPrepare(animation);
20542065
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 100));
2055-
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 40));
2056-
WindowInsets imeInsets0 = builder.build();
2066+
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 0));
2067+
imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, builder.build());
2068+
2069+
verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
2070+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom);
2071+
2072+
// Call onStart and apply new insets - these should be ignored completely
2073+
imeSyncCallback.getAnimationCallback().onStart(animation, null);
2074+
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50));
2075+
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40));
2076+
imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, builder.build());
20572077

2058-
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 30));
2059-
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 40));
2060-
WindowInsets imeInsets1 = builder.build();
2078+
verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
2079+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom);
2080+
2081+
// Progress the animation and ensure that the navigation bar insets have been subtracted
2082+
// from the IME insets
2083+
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 25));
2084+
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40));
2085+
imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList);
2086+
2087+
verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
2088+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom);
20612089

20622090
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50));
2063-
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 40));
2064-
WindowInsets imeInsets2 = builder.build();
2091+
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40));
2092+
imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList);
2093+
2094+
verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
2095+
assertEquals(10, viewportMetricsCaptor.getValue().viewInsetBottom);
2096+
2097+
// End the animation and ensure that the bottom insets match the lastWindowInsets that we set
2098+
// during onPrepare
2099+
imeSyncCallback.getAnimationCallback().onEnd(animation);
2100+
2101+
verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
2102+
assertEquals(100, viewportMetricsCaptor.getValue().viewInsetBottom);
2103+
}
2104+
2105+
@Test
2106+
@TargetApi(30)
2107+
@Config(sdk = 30)
2108+
public void ime_windowInsetsSync_laidOutBehindNavigation_includesNavigationBars() {
2109+
FlutterView testView = spy(new FlutterView(Robolectric.setupActivity(Activity.class)));
2110+
when(testView.getWindowSystemUiVisibility())
2111+
.thenReturn(
2112+
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
2113+
2114+
TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class));
2115+
TextInputPlugin textInputPlugin =
2116+
new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class));
2117+
ImeSyncDeferringInsetsCallback imeSyncCallback = textInputPlugin.getImeSyncCallback();
2118+
FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni));
2119+
FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni));
2120+
when(flutterEngine.getRenderer()).thenReturn(flutterRenderer);
2121+
testView.attachToFlutterEngine(flutterEngine);
20652122

2066-
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 200));
2067-
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 0));
2068-
WindowInsets deferredInsets = builder.build();
2123+
WindowInsetsAnimation animation = mock(WindowInsetsAnimation.class);
2124+
when(animation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
2125+
2126+
List<WindowInsetsAnimation> animationList = new ArrayList();
2127+
animationList.add(animation);
20692128

20702129
ArgumentCaptor<FlutterRenderer.ViewportMetrics> viewportMetricsCaptor =
20712130
ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class);
20722131

2073-
imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, deferredInsets);
2074-
imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, noneInsets);
2132+
WindowInsets.Builder builder = new WindowInsets.Builder();
2133+
2134+
// Set the initial insets and verify that they were set and the bottom view inset is correct
2135+
imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, builder.build());
20752136

20762137
verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
2077-
assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingBottom);
2078-
assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingTop);
20792138
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom);
2080-
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop);
20812139

2140+
// Call onPrepare and set the lastWindowInsets - these should be stored for the end of the
2141+
// animation instead of being applied immediately
20822142
imeSyncCallback.getAnimationCallback().onPrepare(animation);
2083-
imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, deferredInsets);
2143+
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 100));
2144+
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 0));
2145+
imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, builder.build());
2146+
2147+
verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
2148+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom);
2149+
2150+
// Call onStart and apply new insets - these should be ignored completely
20842151
imeSyncCallback.getAnimationCallback().onStart(animation, null);
2085-
// Only the final state call is saved, extra calls are passed on.
2086-
imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, imeInsets2);
2152+
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50));
2153+
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40));
2154+
imeSyncCallback.getInsetsListener().onApplyWindowInsets(testView, builder.build());
20872155

20882156
verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
2089-
// No change, as deferredInset is stored to be passed in onEnd()
2090-
assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingBottom);
2091-
assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingTop);
20922157
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom);
2093-
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop);
20942158

2095-
imeSyncCallback.getAnimationCallback().onProgress(imeInsets0, animationList);
2159+
// Progress the animation and ensure that the navigation bar insets have not been
2160+
// subtracted from the IME insets
2161+
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 25));
2162+
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40));
2163+
imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList);
20962164

20972165
verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
2098-
assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingBottom);
2099-
assertEquals(10, viewportMetricsCaptor.getValue().viewPaddingTop);
2100-
assertEquals(60, viewportMetricsCaptor.getValue().viewInsetBottom);
2101-
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop);
2166+
assertEquals(25, viewportMetricsCaptor.getValue().viewInsetBottom);
21022167

2103-
imeSyncCallback.getAnimationCallback().onProgress(imeInsets1, animationList);
2168+
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50));
2169+
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40));
2170+
imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList);
21042171

21052172
verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
2106-
assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingBottom);
2107-
assertEquals(10, viewportMetricsCaptor.getValue().viewPaddingTop);
2108-
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom); // Cannot be negative
2109-
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop);
2173+
assertEquals(50, viewportMetricsCaptor.getValue().viewInsetBottom);
21102174

2175+
// End the animation and ensure that the bottom insets match the lastWindowInsets that we set
2176+
// during onPrepare
21112177
imeSyncCallback.getAnimationCallback().onEnd(animation);
21122178

21132179
verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
2114-
// Values should be of deferredInsets, not imeInsets2
2115-
assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingBottom);
2116-
assertEquals(10, viewportMetricsCaptor.getValue().viewPaddingTop);
2117-
assertEquals(200, viewportMetricsCaptor.getValue().viewInsetBottom);
2118-
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop);
2180+
assertEquals(100, viewportMetricsCaptor.getValue().viewInsetBottom);
21192181
}
21202182

21212183
@Test

0 commit comments

Comments
 (0)