diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index f5714f1fd0fa6..64a5e9b0cced1 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -213,6 +213,7 @@ android_java_sources = [ "io/flutter/plugin/common/StandardMethodCodec.java", "io/flutter/plugin/common/StringCodec.java", "io/flutter/plugin/editing/FlutterTextUtils.java", + "io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java", "io/flutter/plugin/editing/InputConnectionAdaptor.java", "io/flutter/plugin/editing/TextInputPlugin.java", "io/flutter/plugin/localization/LocalizationPlugin.java", diff --git a/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java b/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java new file mode 100644 index 0000000000000..93add451ea7e3 --- /dev/null +++ b/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java @@ -0,0 +1,175 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugin.editing; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.graphics.Insets; +import android.view.View; +import android.view.WindowInsets; +import android.view.WindowInsetsAnimation; +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; +import java.util.List; + +// Loosely based off of +// https://github.com/android/user-interface-samples/blob/master/WindowInsetsAnimation/app/src/main/java/com/google/android/samples/insetsanimation/RootViewDeferringInsetsCallback.kt +// +// When the IME is shown or hidden, it immediately sends an onApplyWindowInsets call +// with the final state of the IME. This initial call disrupts the animation, which +// causes a flicker in the beginning. +// +// To fix this, this class extends WindowInsetsAnimation.Callback and implements +// OnApplyWindowInsetsListener. We capture and defer the initial call to +// onApplyWindowInsets while the animation completes. When the animation +// finishes, we can then release the call by invoking it in the onEnd callback +// +// The WindowInsetsAnimation.Callback extension forwards the new state of the +// IME inset from onProgress() to the framework. We also make use of the +// onStart callback to detect which calls to onApplyWindowInsets would +// interrupt the animation and defer it. +// +// By implementing OnApplyWindowInsetsListener, we are able to capture Android's +// attempts to call the FlutterView's onApplyWindowInsets. When a call to onStart +// occurs, we can mark any non-animation calls to onApplyWindowInsets() that +// occurs between prepare and start as deferred by using this class' wrapper +// implementation to cache the WindowInsets passed in and turn the current call into +// a no-op. When onEnd indicates the end of the animation, the deferred call is +// dispatched again, this time avoiding any flicker since the animation is now +// complete. +@VisibleForTesting +@TargetApi(30) +@RequiresApi(30) +@SuppressLint({"NewApi", "Override"}) +@Keep +class ImeSyncDeferringInsetsCallback extends WindowInsetsAnimation.Callback + implements View.OnApplyWindowInsetsListener { + private int overlayInsetTypes; + private int deferredInsetTypes; + + private View view; + private WindowInsets lastWindowInsets; + // True when an animation that matches deferredInsetTypes is active. + // + // While this is active, this class will capture the initial window inset + // sent into lastWindowInsets by flagging needsSave to true, and will hold + // onto the intitial inset until the animation is completed, when it will + // re-dispatch the inset change. + private boolean animating = false; + // When an animation begins, android sends a WindowInset with the final + // state of the animation. When needsSave is true, we know to capture this + // initial WindowInset. + private boolean needsSave = false; + + ImeSyncDeferringInsetsCallback( + @NonNull View view, int overlayInsetTypes, int deferredInsetTypes) { + super(WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE); + this.overlayInsetTypes = overlayInsetTypes; + this.deferredInsetTypes = deferredInsetTypes; + this.view = view; + } + + void install() { + view.setWindowInsetsAnimationCallback(this); + view.setOnApplyWindowInsetsListener(this); + } + + void remove() { + view.setWindowInsetsAnimationCallback(null); + view.setOnApplyWindowInsetsListener(null); + } + + @Override + public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { + this.view = view; + if (needsSave) { + // Store the view and insets for us in onEnd() below. This captured inset + // is not part of the animation and instead, represents the final state + // of the inset after the animation is completed. Thus, we defer the processing + // of this WindowInset until the animation completes. + lastWindowInsets = windowInsets; + needsSave = false; + } + if (animating) { + // While animation is running, we consume the insets to prevent disrupting + // the animation, which skips this implementation and calls the view's + // onApplyWindowInsets directly to avoid being consumed here. + return WindowInsets.CONSUMED; + } + + // If no animation is happening, pass the insets on to the view's own + // inset handling. + return view.onApplyWindowInsets(windowInsets); + } + + @Override + public void onPrepare(WindowInsetsAnimation animation) { + if ((animation.getTypeMask() & deferredInsetTypes) != 0) { + animating = true; + needsSave = true; + } + } + + @Override + public WindowInsets onProgress( + WindowInsets insets, List runningAnimations) { + if (!animating || needsSave) { + return insets; + } + boolean matching = false; + for (WindowInsetsAnimation animation : runningAnimations) { + if ((animation.getTypeMask() & deferredInsetTypes) != 0) { + matching = true; + continue; + } + } + if (!matching) { + return insets; + } + 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)); + 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 + // onEnd instead. + view.onApplyWindowInsets(builder.build()); + return insets; + } + + @Override + public void onEnd(WindowInsetsAnimation animation) { + if (animating && (animation.getTypeMask() & deferredInsetTypes) != 0) { + // If we deferred the IME insets and an IME animation has finished, we need to reset + // the flags + animating = false; + + // And finally dispatch the deferred insets to the view now. + // Ideally we would just call view.requestApplyInsets() and let the normal dispatch + // cycle happen, but this happens too late resulting in a visual flicker. + // Instead we manually dispatch the most recent WindowInsets to the view. + if (lastWindowInsets != null && view != null) { + view.dispatchApplyWindowInsets(lastWindowInsets); + } + } + } +} diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 3090c6fba81fa..2dc272a7d74be 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -5,9 +5,7 @@ package io.flutter.plugin.editing; import android.annotation.SuppressLint; -import android.annotation.TargetApi; import android.content.Context; -import android.graphics.Insets; import android.graphics.Rect; import android.os.Build; import android.os.Bundle; @@ -19,7 +17,6 @@ import android.view.View; import android.view.ViewStructure; import android.view.WindowInsets; -import android.view.WindowInsetsAnimation; import android.view.autofill.AutofillId; import android.view.autofill.AutofillManager; import android.view.autofill.AutofillValue; @@ -28,15 +25,12 @@ import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodManager; import android.view.inputmethod.InputMethodSubtype; -import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; import io.flutter.embedding.engine.systemchannels.TextInputChannel; import io.flutter.plugin.platform.PlatformViewsController; import java.util.HashMap; -import java.util.List; /** Android implementation of the text input plugin. */ public class TextInputPlugin { @@ -91,8 +85,7 @@ public TextInputPlugin( mask, // Overlay, insets that should be merged with the deferred insets WindowInsets.Type.ime() // Deferred, insets that will animate ); - mView.setWindowInsetsAnimationCallback(imeSyncCallback); - mView.setOnApplyWindowInsetsListener(imeSyncCallback); + imeSyncCallback.install(); } this.textInputChannel = textInputChannel; @@ -164,154 +157,6 @@ public void sendAppPrivateCommand(String action, Bundle data) { restartAlwaysRequired = isRestartAlwaysRequired(); } - // Loosely based off of - // https://github.com/android/user-interface-samples/blob/master/WindowInsetsAnimation/app/src/main/java/com/google/android/samples/insetsanimation/RootViewDeferringInsetsCallback.kt - // - // When the IME is shown or hidden, it immediately sends an onApplyWindowInsets call - // with the final state of the IME. This initial call disrupts the animation, which - // causes a flicker in the beginning. - // - // To fix this, this class extends WindowInsetsAnimation.Callback and implements - // OnApplyWindowInsetsListener. We capture and defer the initial call to - // onApplyWindowInsets while the animation completes. When the animation - // finishes, we can then release the call by invoking it in the onEnd callback - // - // The WindowInsetsAnimation.Callback extension forwards the new state of the - // IME inset from onProgress() to the framework. We also make use of the - // onStart callback to detect which calls to onApplyWindowInsets would - // interrupt the animation and defer it. - // - // By implementing OnApplyWindowInsetsListener, we are able to capture Android's - // attempts to call the FlutterView's onApplyWindowInsets. When a call to onStart - // occurs, we can mark any non-animation calls to onApplyWindowInsets() that - // occurs between prepare and start as deferred by using this class' wrapper - // implementation to cache the WindowInsets passed in and turn the current call into - // a no-op. When onEnd indicates the end of the animation, the deferred call is - // dispatched again, this time avoiding any flicker since the animation is now - // complete. - @VisibleForTesting - @TargetApi(30) - @RequiresApi(30) - @SuppressLint({"NewApi", "Override"}) - @Keep - class ImeSyncDeferringInsetsCallback extends WindowInsetsAnimation.Callback - implements View.OnApplyWindowInsetsListener { - private int overlayInsetTypes; - private int deferredInsetTypes; - - private View view; - private WindowInsets lastWindowInsets; - // True when an animation that matches deferredInsetTypes is active. - // - // While this is active, this class will capture the initial window inset - // sent into lastWindowInsets by flagging needsSave to true, and will hold - // onto the intitial inset until the animation is completed, when it will - // re-dispatch the inset change. - private boolean animating = false; - // When an animation begins, android sends a WindowInset with the final - // state of the animation. When needsSave is true, we know to capture this - // initial WindowInset. - private boolean needsSave = false; - - ImeSyncDeferringInsetsCallback( - @NonNull View view, int overlayInsetTypes, int deferredInsetTypes) { - super(WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE); - this.overlayInsetTypes = overlayInsetTypes; - this.deferredInsetTypes = deferredInsetTypes; - this.view = view; - } - - @Override - public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { - this.view = view; - if (needsSave) { - // Store the view and insets for us in onEnd() below. This captured inset - // is not part of the animation and instead, represents the final state - // of the inset after the animation is completed. Thus, we defer the processing - // of this WindowInset until the animation completes. - lastWindowInsets = windowInsets; - needsSave = false; - } - if (animating) { - // While animation is running, we consume the insets to prevent disrupting - // the animation, which skips this implementation and calls the view's - // onApplyWindowInsets directly to avoid being consumed here. - return WindowInsets.CONSUMED; - } - - // If no animation is happening, pass the insets on to the view's own - // inset handling. - return view.onApplyWindowInsets(windowInsets); - } - - @Override - public void onPrepare(WindowInsetsAnimation animation) { - if ((animation.getTypeMask() & deferredInsetTypes) != 0) { - animating = true; - needsSave = true; - } - } - - @Override - public WindowInsets onProgress( - WindowInsets insets, List runningAnimations) { - if (!animating || needsSave) { - return insets; - } - boolean matching = false; - for (WindowInsetsAnimation animation : runningAnimations) { - if ((animation.getTypeMask() & deferredInsetTypes) != 0) { - matching = true; - continue; - } - } - if (!matching) { - return insets; - } - 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)); - 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 - // onEnd instead. - view.onApplyWindowInsets(builder.build()); - return insets; - } - - @Override - public void onEnd(WindowInsetsAnimation animation) { - if (animating && (animation.getTypeMask() & deferredInsetTypes) != 0) { - // If we deferred the IME insets and an IME animation has finished, we need to reset - // the flags - animating = false; - - // And finally dispatch the deferred insets to the view now. - // Ideally we would just call view.requestApplyInsets() and let the normal dispatch - // cycle happen, but this happens too late resulting in a visual flicker. - // Instead we manually dispatch the most recent WindowInsets to the view. - if (lastWindowInsets != null && view != null) { - view.dispatchApplyWindowInsets(lastWindowInsets); - } - } - } - } - @NonNull public InputMethodManager getInputMethodManager() { return mImm; @@ -364,9 +209,8 @@ public void unlockPlatformViewInputConnection() { public void destroy() { platformViewsController.detachTextInputPlugin(); textInputChannel.setTextInputMethodHandler(null); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - mView.setWindowInsetsAnimationCallback(null); - mView.setOnApplyWindowInsetsListener(null); + if (imeSyncCallback != null) { + imeSyncCallback.remove(); } } diff --git a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java index b038eb56000f3..7cec8faafb6fd 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -652,8 +652,7 @@ public void ime_windowInsetsSync() { TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class)); TextInputPlugin textInputPlugin = new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); - TextInputPlugin.ImeSyncDeferringInsetsCallback imeSyncCallback = - textInputPlugin.getImeSyncCallback(); + ImeSyncDeferringInsetsCallback imeSyncCallback = textInputPlugin.getImeSyncCallback(); FlutterEngine flutterEngine = spy(new FlutterEngine(RuntimeEnvironment.application, mockFlutterLoader, mockFlutterJni)); FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni));