Skip to content

Commit 242d522

Browse files
authored
[Android R] Sync keyboard animation with view insets vs Android 11/R/API 30 WindowInsetsAnimation (#20843)
1 parent 9fc9cb2 commit 242d522

File tree

2 files changed

+270
-0
lines changed

2 files changed

+270
-0
lines changed

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

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
package io.flutter.plugin.editing;
66

77
import android.annotation.SuppressLint;
8+
import android.annotation.TargetApi;
89
import android.content.Context;
10+
import android.graphics.Insets;
911
import android.graphics.Rect;
1012
import android.os.Build;
1113
import android.os.Bundle;
@@ -16,6 +18,8 @@
1618
import android.util.SparseArray;
1719
import android.view.View;
1820
import android.view.ViewStructure;
21+
import android.view.WindowInsets;
22+
import android.view.WindowInsetsAnimation;
1923
import android.view.autofill.AutofillId;
2024
import android.view.autofill.AutofillManager;
2125
import android.view.autofill.AutofillValue;
@@ -26,10 +30,12 @@
2630
import android.view.inputmethod.InputMethodSubtype;
2731
import androidx.annotation.NonNull;
2832
import androidx.annotation.Nullable;
33+
import androidx.annotation.RequiresApi;
2934
import androidx.annotation.VisibleForTesting;
3035
import io.flutter.embedding.engine.systemchannels.TextInputChannel;
3136
import io.flutter.plugin.platform.PlatformViewsController;
3237
import java.util.HashMap;
38+
import java.util.List;
3339

3440
/** Android implementation of the text input plugin. */
3541
public class TextInputPlugin {
@@ -46,13 +52,15 @@ public class TextInputPlugin {
4652
@NonNull private PlatformViewsController platformViewsController;
4753
@Nullable private Rect lastClientRect;
4854
private final boolean restartAlwaysRequired;
55+
private ImeSyncDeferringInsetsCallback imeSyncCallback;
4956

5057
// When true following calls to createInputConnection will return the cached lastInputConnection
5158
// if the input
5259
// target is a platform view. See the comments on lockPlatformViewInputConnection for more
5360
// details.
5461
private boolean isInputConnectionLocked;
5562

63+
@SuppressLint("NewApi")
5664
public TextInputPlugin(
5765
View view,
5866
@NonNull TextInputChannel textInputChannel,
@@ -65,6 +73,27 @@ public TextInputPlugin(
6573
afm = null;
6674
}
6775

76+
// Sets up syncing ime insets with the framework, allowing
77+
// the Flutter view to grow and shrink to accomodate Android
78+
// controlled keyboard animations.
79+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
80+
int mask = 0;
81+
if ((View.SYSTEM_UI_FLAG_HIDE_NAVIGATION & mView.getWindowSystemUiVisibility()) == 0) {
82+
mask = mask | WindowInsets.Type.navigationBars();
83+
}
84+
if ((View.SYSTEM_UI_FLAG_FULLSCREEN & mView.getWindowSystemUiVisibility()) == 0) {
85+
mask = mask | WindowInsets.Type.statusBars();
86+
}
87+
imeSyncCallback =
88+
new ImeSyncDeferringInsetsCallback(
89+
view,
90+
mask, // Overlay, insets that should be merged with the deferred insets
91+
WindowInsets.Type.ime() // Deferred, insets that will animate
92+
);
93+
mView.setWindowInsetsAnimationCallback(imeSyncCallback);
94+
mView.setOnApplyWindowInsetsListener(imeSyncCallback);
95+
}
96+
6897
this.textInputChannel = textInputChannel;
6998
textInputChannel.setTextInputMethodHandler(
7099
new TextInputChannel.TextInputMethodHandler() {
@@ -134,6 +163,139 @@ public void sendAppPrivateCommand(String action, Bundle data) {
134163
restartAlwaysRequired = isRestartAlwaysRequired();
135164
}
136165

166+
// Loosely based off of
167+
// https://github.com/android/user-interface-samples/blob/master/WindowInsetsAnimation/app/src/main/java/com/google/android/samples/insetsanimation/RootViewDeferringInsetsCallback.kt
168+
//
169+
// When the IME is shown or hidden, it immediately sends an onApplyWindowInsets call
170+
// with the final state of the IME. This initial call disrupts the animation, which
171+
// causes a flicker in the beginning.
172+
//
173+
// To fix this, this class extends WindowInsetsAnimation.Callback and implements
174+
// OnApplyWindowInsetsListener. We capture and defer the initial call to
175+
// onApplyWindowInsets while the animation completes. When the animation
176+
// finishes, we can then release the call by invoking it in the onEnd callback
177+
//
178+
// The WindowInsetsAnimation.Callback extension forwards the new state of the
179+
// IME inset from onProgress() to the framework. We also make use of the
180+
// onStart callback to detect which calls to onApplyWindowInsets would
181+
// interrupt the animation and defer it.
182+
//
183+
// By implementing OnApplyWindowInsetsListener, we are able to capture Android's
184+
// attempts to call the FlutterView's onApplyWindowInsets. When a call to onStart
185+
// occurs, we can mark any non-animation calls to onApplyWindowInsets() that
186+
// occurs between prepare and start as deferred by using this class' wrapper
187+
// implementation to cache the WindowInsets passed in and turn the current call into
188+
// a no-op. When onEnd indicates the end of the animation, the deferred call is
189+
// dispatched again, this time avoiding any flicker since the animation is now
190+
// complete.
191+
@VisibleForTesting
192+
@TargetApi(30)
193+
@RequiresApi(30)
194+
@SuppressLint({"NewApi", "Override"})
195+
class ImeSyncDeferringInsetsCallback extends WindowInsetsAnimation.Callback
196+
implements View.OnApplyWindowInsetsListener {
197+
private int overlayInsetTypes;
198+
private int deferredInsetTypes;
199+
200+
private View view;
201+
private WindowInsets lastWindowInsets;
202+
private boolean started = false;
203+
204+
ImeSyncDeferringInsetsCallback(
205+
@NonNull View view, int overlayInsetTypes, int deferredInsetTypes) {
206+
super(WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE);
207+
this.overlayInsetTypes = overlayInsetTypes;
208+
this.deferredInsetTypes = deferredInsetTypes;
209+
this.view = view;
210+
}
211+
212+
@Override
213+
public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) {
214+
this.view = view;
215+
if (started) {
216+
// While animation is running, we consume the insets to prevent disrupting
217+
// the animation, which skips this implementation and calls the view's
218+
// onApplyWindowInsets directly to avoid being consumed here.
219+
return WindowInsets.CONSUMED;
220+
}
221+
222+
// Store the view and insets for us in onEnd() below
223+
lastWindowInsets = windowInsets;
224+
225+
// If no animation is happening, pass the insets on to the view's own
226+
// inset handling.
227+
return view.onApplyWindowInsets(windowInsets);
228+
}
229+
230+
@Override
231+
public WindowInsetsAnimation.Bounds onStart(
232+
WindowInsetsAnimation animation, WindowInsetsAnimation.Bounds bounds) {
233+
if ((animation.getTypeMask() & deferredInsetTypes) != 0) {
234+
started = true;
235+
}
236+
return bounds;
237+
}
238+
239+
@Override
240+
public WindowInsets onProgress(
241+
WindowInsets insets, List<WindowInsetsAnimation> runningAnimations) {
242+
if (!started) {
243+
return insets;
244+
}
245+
boolean matching = false;
246+
for (WindowInsetsAnimation animation : runningAnimations) {
247+
if ((animation.getTypeMask() & deferredInsetTypes) != 0) {
248+
matching = true;
249+
continue;
250+
}
251+
}
252+
if (!matching) {
253+
return insets;
254+
}
255+
WindowInsets.Builder builder = new WindowInsets.Builder(lastWindowInsets);
256+
// Overlay the ime-only insets with the full insets.
257+
//
258+
// The IME insets passed in by onProgress assumes that the entire animation
259+
// occurs above any present navigation and status bars. This causes the
260+
// IME inset to be too large for the animation. To remedy this, we merge the
261+
// IME inset with other insets present via a subtract + reLu, which causes the
262+
// IME inset to be overlaid with any bars present.
263+
Insets newImeInsets =
264+
Insets.of(
265+
0,
266+
0,
267+
0,
268+
Math.max(
269+
insets.getInsets(deferredInsetTypes).bottom
270+
- insets.getInsets(overlayInsetTypes).bottom,
271+
0));
272+
builder.setInsets(deferredInsetTypes, newImeInsets);
273+
// Directly call onApplyWindowInsets of the view as we do not want to pass through
274+
// the onApplyWindowInsets defined in this class, which would consume the insets
275+
// as if they were a non-animation inset change and cache it for re-dispatch in
276+
// onEnd instead.
277+
view.onApplyWindowInsets(builder.build());
278+
return insets;
279+
}
280+
281+
@Override
282+
public void onEnd(WindowInsetsAnimation animation) {
283+
if (started && (animation.getTypeMask() & deferredInsetTypes) != 0) {
284+
// If we deferred the IME insets and an IME animation has finished, we need to reset
285+
// the flags
286+
started = false;
287+
288+
// And finally dispatch the deferred insets to the view now.
289+
// Ideally we would just call view.requestApplyInsets() and let the normal dispatch
290+
// cycle happen, but this happens too late resulting in a visual flicker.
291+
// Instead we manually dispatch the most recent WindowInsets to the view.
292+
if (lastWindowInsets != null && view != null) {
293+
view.dispatchApplyWindowInsets(lastWindowInsets);
294+
}
295+
}
296+
}
297+
}
298+
137299
@NonNull
138300
public InputMethodManager getInputMethodManager() {
139301
return mImm;
@@ -144,6 +306,11 @@ Editable getEditable() {
144306
return mEditable;
145307
}
146308

309+
@VisibleForTesting
310+
ImeSyncDeferringInsetsCallback getImeSyncCallback() {
311+
return imeSyncCallback;
312+
}
313+
147314
/**
148315
* Use the current platform view input connection until unlockPlatformViewInputConnection is
149316
* called.
@@ -177,9 +344,14 @@ public void unlockPlatformViewInputConnection() {
177344
*
178345
* <p>The TextInputPlugin instance should not be used after calling this.
179346
*/
347+
@SuppressLint("NewApi")
180348
public void destroy() {
181349
platformViewsController.detachTextInputPlugin();
182350
textInputChannel.setTextInputMethodHandler(null);
351+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
352+
mView.setWindowInsetsAnimationCallback(null);
353+
mView.setOnApplyWindowInsetsListener(null);
354+
}
183355
}
184356

185357
private static int inputTypeFromTextInputType(

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,36 +15,45 @@
1515
import static org.mockito.Mockito.verify;
1616
import static org.mockito.Mockito.when;
1717

18+
import android.annotation.TargetApi;
1819
import android.content.Context;
1920
import android.content.res.AssetManager;
21+
import android.graphics.Insets;
2022
import android.os.Build;
2123
import android.os.Bundle;
2224
import android.provider.Settings;
2325
import android.util.SparseIntArray;
2426
import android.view.KeyEvent;
2527
import android.view.View;
2628
import android.view.ViewStructure;
29+
import android.view.WindowInsets;
30+
import android.view.WindowInsetsAnimation;
2731
import android.view.inputmethod.CursorAnchorInfo;
2832
import android.view.inputmethod.EditorInfo;
2933
import android.view.inputmethod.InputConnection;
3034
import android.view.inputmethod.InputMethodManager;
3135
import android.view.inputmethod.InputMethodSubtype;
3236
import io.flutter.embedding.android.FlutterView;
37+
import io.flutter.embedding.engine.FlutterEngine;
3338
import io.flutter.embedding.engine.FlutterJNI;
3439
import io.flutter.embedding.engine.dart.DartExecutor;
40+
import io.flutter.embedding.engine.loader.FlutterLoader;
41+
import io.flutter.embedding.engine.renderer.FlutterRenderer;
3542
import io.flutter.embedding.engine.systemchannels.TextInputChannel;
3643
import io.flutter.plugin.common.BinaryMessenger;
3744
import io.flutter.plugin.common.JSONMethodCodec;
3845
import io.flutter.plugin.common.MethodCall;
3946
import io.flutter.plugin.platform.PlatformViewsController;
4047
import java.nio.ByteBuffer;
4148
import java.util.ArrayList;
49+
import java.util.List;
4250
import org.json.JSONArray;
4351
import org.json.JSONException;
4452
import org.json.JSONObject;
4553
import org.junit.Test;
4654
import org.junit.runner.RunWith;
4755
import org.mockito.ArgumentCaptor;
56+
import org.mockito.Mock;
4857
import org.robolectric.RobolectricTestRunner;
4958
import org.robolectric.RuntimeEnvironment;
5059
import org.robolectric.annotation.Config;
@@ -57,6 +66,9 @@
5766
@Config(manifest = Config.NONE, shadows = TextInputPluginTest.TestImm.class)
5867
@RunWith(RobolectricTestRunner.class)
5968
public class TextInputPluginTest {
69+
@Mock FlutterJNI mockFlutterJni;
70+
@Mock FlutterLoader mockFlutterLoader;
71+
6072
// Verifies the method and arguments for a captured method call.
6173
private void verifyMethodCall(ByteBuffer buffer, String methodName, String[] expectedArgs)
6274
throws JSONException {
@@ -632,6 +644,92 @@ public void sendAppPrivateCommand_hasData() throws JSONException {
632644
assertEquals("actionData", bundleCaptor.getValue().getCharSequence("data"));
633645
}
634646

647+
@Test
648+
@TargetApi(30)
649+
@Config(sdk = 30)
650+
public void ime_windowInsetsSync() {
651+
FlutterView testView = new FlutterView(RuntimeEnvironment.application);
652+
TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class));
653+
TextInputPlugin textInputPlugin =
654+
new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class));
655+
TextInputPlugin.ImeSyncDeferringInsetsCallback imeSyncCallback =
656+
textInputPlugin.getImeSyncCallback();
657+
FlutterEngine flutterEngine =
658+
spy(new FlutterEngine(RuntimeEnvironment.application, mockFlutterLoader, mockFlutterJni));
659+
FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni));
660+
when(flutterEngine.getRenderer()).thenReturn(flutterRenderer);
661+
testView.attachToFlutterEngine(flutterEngine);
662+
663+
WindowInsetsAnimation animation = mock(WindowInsetsAnimation.class);
664+
when(animation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
665+
666+
List<WindowInsetsAnimation> animationList = new ArrayList();
667+
animationList.add(animation);
668+
669+
WindowInsets.Builder builder = new WindowInsets.Builder();
670+
WindowInsets noneInsets = builder.build();
671+
672+
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 100));
673+
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 40));
674+
WindowInsets imeInsets0 = builder.build();
675+
676+
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 30));
677+
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 40));
678+
WindowInsets imeInsets1 = builder.build();
679+
680+
builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 200));
681+
builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 0));
682+
WindowInsets deferredInsets = builder.build();
683+
684+
ArgumentCaptor<FlutterRenderer.ViewportMetrics> viewportMetricsCaptor =
685+
ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class);
686+
687+
imeSyncCallback.onApplyWindowInsets(testView, deferredInsets);
688+
imeSyncCallback.onApplyWindowInsets(testView, noneInsets);
689+
690+
verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture());
691+
assertEquals(0, viewportMetricsCaptor.getValue().paddingBottom);
692+
assertEquals(0, viewportMetricsCaptor.getValue().paddingTop);
693+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom);
694+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop);
695+
696+
imeSyncCallback.onPrepare(animation);
697+
imeSyncCallback.onApplyWindowInsets(testView, deferredInsets);
698+
imeSyncCallback.onStart(animation, null);
699+
700+
verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture());
701+
// No change, as deferredInset is stored to be passed in onEnd()
702+
assertEquals(0, viewportMetricsCaptor.getValue().paddingBottom);
703+
assertEquals(0, viewportMetricsCaptor.getValue().paddingTop);
704+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom);
705+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop);
706+
707+
imeSyncCallback.onProgress(imeInsets0, animationList);
708+
709+
verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture());
710+
assertEquals(40, viewportMetricsCaptor.getValue().paddingBottom);
711+
assertEquals(10, viewportMetricsCaptor.getValue().paddingTop);
712+
assertEquals(60, viewportMetricsCaptor.getValue().viewInsetBottom);
713+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop);
714+
715+
imeSyncCallback.onProgress(imeInsets1, animationList);
716+
717+
verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture());
718+
assertEquals(40, viewportMetricsCaptor.getValue().paddingBottom);
719+
assertEquals(10, viewportMetricsCaptor.getValue().paddingTop);
720+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom); // Cannot be negative
721+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop);
722+
723+
imeSyncCallback.onEnd(animation);
724+
725+
verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture());
726+
// Values should be of deferredInsets
727+
assertEquals(0, viewportMetricsCaptor.getValue().paddingBottom);
728+
assertEquals(10, viewportMetricsCaptor.getValue().paddingTop);
729+
assertEquals(200, viewportMetricsCaptor.getValue().viewInsetBottom);
730+
assertEquals(0, viewportMetricsCaptor.getValue().viewInsetTop);
731+
}
732+
635733
interface EventHandler {
636734
void sendAppPrivateCommand(View view, String action, Bundle data);
637735
}

0 commit comments

Comments
 (0)