Skip to content

Commit

Permalink
Extract the WindowInsetsAnimation.Callback subclass into a separate c…
Browse files Browse the repository at this point in the history
…lass that will be lazily loaded

WindowInsetsAnimation.Callback was introduced in API level 30.  This
PR moves the text input plugin's WindowInsetsAnimation.Callback subclass
into a class that will only be loaded if the embedding has checked for a
sufficient API level.

See flutter/flutter#66908
  • Loading branch information
jason-simmons committed Oct 1, 2020
1 parent 83b9df9 commit d763bf7
Show file tree
Hide file tree
Showing 4 changed files with 180 additions and 161 deletions.
1 change: 1 addition & 0 deletions shell/platform/android/BUILD.gn
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<WindowInsetsAnimation> 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);
}
}
}
}
162 changes: 3 additions & 159 deletions shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<WindowInsetsAnimation> 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;
Expand Down Expand Up @@ -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();
}
}

Expand Down
Loading

0 comments on commit d763bf7

Please sign in to comment.