55package io .flutter .plugin .editing ;
66
77import android .annotation .SuppressLint ;
8+ import android .annotation .TargetApi ;
89import android .content .Context ;
10+ import android .graphics .Insets ;
911import android .graphics .Rect ;
1012import android .os .Build ;
1113import android .os .Bundle ;
1618import android .util .SparseArray ;
1719import android .view .View ;
1820import android .view .ViewStructure ;
21+ import android .view .WindowInsets ;
22+ import android .view .WindowInsetsAnimation ;
1923import android .view .autofill .AutofillId ;
2024import android .view .autofill .AutofillManager ;
2125import android .view .autofill .AutofillValue ;
2630import android .view .inputmethod .InputMethodSubtype ;
2731import androidx .annotation .NonNull ;
2832import androidx .annotation .Nullable ;
33+ import androidx .annotation .RequiresApi ;
2934import androidx .annotation .VisibleForTesting ;
3035import io .flutter .embedding .engine .systemchannels .TextInputChannel ;
3136import io .flutter .plugin .platform .PlatformViewsController ;
3237import java .util .HashMap ;
38+ import java .util .List ;
3339
3440/** Android implementation of the text input plugin. */
3541public 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 (
0 commit comments