Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 3c183b4a authored by Johannes Gallmann's avatar Johannes Gallmann
Browse files

Predictive IME back animation

Known issues:
1. There is no predictive back animation played for adjustResize cases
2. This doesn't work great for adjustPan cases yet
3. A second back gesture is ignored if it is started during the post-commit (and triggerBack) phase.

Bug: 322836622
Flag: ACONFIG android.view.inputmethod.predictive_back_ime DISABLED
Test: separate CL
Change-Id: Ic8ddfffb8550cb9875e329d7a9e8b39711a512bd
parent 69a62d57
Loading
Loading
Loading
Loading
+18 −8
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@

package android.inputmethodservice;

import static android.view.inputmethod.Flags.predictiveBackIme;
import static android.inputmethodservice.InputMethodServiceProto.CANDIDATES_VIEW_STARTED;
import static android.inputmethodservice.InputMethodServiceProto.CANDIDATES_VISIBILITY;
import static android.inputmethodservice.InputMethodServiceProto.CONFIGURATION;
@@ -3098,7 +3099,7 @@ public class InputMethodService extends AbstractInputMethodService {
        cancelImeSurfaceRemoval();
        mInShowWindow = false;
        Trace.traceEnd(TRACE_TAG_WINDOW_MANAGER);
        registerCompatOnBackInvokedCallback();
        registerDefaultOnBackInvokedCallback();
    }


@@ -3107,18 +3108,27 @@ public class InputMethodService extends AbstractInputMethodService {
     *  back dispatching is enabled. We keep the {@link KeyEvent#KEYCODE_BACK} based legacy code
     *  around to handle back on older devices.
     */
    private void registerCompatOnBackInvokedCallback() {
    private void registerDefaultOnBackInvokedCallback() {
        if (mBackCallbackRegistered) {
            return;
        }
        if (mWindow != null) {
            if (getApplicationInfo().isOnBackInvokedCallbackEnabled() && predictiveBackIme()) {
                // Register the compat callback as system-callback if IME has opted in for
                // predictive back (and predictiveBackIme feature flag is enabled). This indicates
                // to the receiving process (application process) that a predictive IME dismiss
                // animation may be played instead of invoking the callback.
                mWindow.getOnBackInvokedDispatcher().registerSystemOnBackInvokedCallback(
                        mCompatBackCallback);
            } else {
                mWindow.getOnBackInvokedDispatcher().registerOnBackInvokedCallback(
                        OnBackInvokedDispatcher.PRIORITY_DEFAULT, mCompatBackCallback);
            }
            mBackCallbackRegistered = true;
        }
    }

    private void unregisterCompatOnBackInvokedCallback() {
    private void unregisterDefaultOnBackInvokedCallback() {
        if (!mBackCallbackRegistered) {
            return;
        }
@@ -3251,7 +3261,7 @@ public class InputMethodService extends AbstractInputMethodService {
        }
        mLastWasInFullscreenMode = mIsFullscreen;
        updateFullscreenMode();
        unregisterCompatOnBackInvokedCallback();
        unregisterDefaultOnBackInvokedCallback();
    }

    /**
@@ -3328,7 +3338,7 @@ public class InputMethodService extends AbstractInputMethodService {
        // Back callback is typically unregistered in {@link #hideWindow()}, but it's possible
        // for {@link #doFinishInput()} to be called without {@link #hideWindow()} so we also
        // unregister here.
        unregisterCompatOnBackInvokedCallback();
        unregisterDefaultOnBackInvokedCallback();
    }

    void doStartInput(InputConnection ic, EditorInfo editorInfo, boolean restarting) {
@@ -4473,7 +4483,7 @@ public class InputMethodService extends AbstractInputMethodService {
    private void compatHandleBack() {
        if (!mDecorViewVisible) {
            Log.e(TAG, "Back callback invoked on a hidden IME. Removing the callback...");
            unregisterCompatOnBackInvokedCallback();
            unregisterDefaultOnBackInvokedCallback();
            return;
        }
        final KeyEvent downEvent = createBackKeyEvent(
+220 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package android.view;

import static android.view.InsetsController.ANIMATION_TYPE_USER;
import static android.view.WindowInsets.Type.ime;
import static android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
import static android.view.WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.graphics.Insets;
import android.util.Log;
import android.view.animation.Interpolator;
import android.view.animation.PathInterpolator;
import android.window.BackEvent;
import android.window.OnBackAnimationCallback;

/**
 * Controller for IME predictive back animation
 *
 * @hide
 */
public class ImeBackAnimationController implements OnBackAnimationCallback {

    private static final String TAG = "ImeBackAnimationController";
    private static final int POST_COMMIT_DURATION_MS = 200;
    private static final int POST_COMMIT_CANCEL_DURATION_MS = 50;
    private static final float PEEK_FRACTION = 0.1f;
    private static final Interpolator STANDARD_DECELERATE = new PathInterpolator(0f, 0f, 0f, 1f);
    private static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator(
            0.05f, 0.7f, 0.1f, 1f);
    private static final Interpolator STANDARD_ACCELERATE = new PathInterpolator(0.3f, 0f, 1f, 1f);

    private final InsetsController mInsetsController;
    private final ViewRootImpl mViewRoot;
    private WindowInsetsAnimationController mWindowInsetsAnimationController = null;
    private ValueAnimator mPostCommitAnimator = null;
    private float mLastProgress = 0f;
    private boolean mTriggerBack = false;
    private boolean mIsPreCommitAnimationInProgress = false;

    public ImeBackAnimationController(ViewRootImpl viewRoot) {
        mInsetsController = viewRoot.getInsetsController();
        mViewRoot = viewRoot;
    }

    @Override
    public void onBackStarted(@NonNull BackEvent backEvent) {
        if (isAdjustResize()) {
            // There is no good solution for a predictive back animation if the app uses
            // adjustResize, since we can't relayout the whole app for every frame. We also don't
            // want to reveal any black areas behind the IME. Therefore let's not play any animation
            // in that case for now.
            Log.d(TAG, "onBackStarted -> not playing predictive back animation due to softinput"
                    + " mode adjustResize");
            return;
        }
        if (isHideAnimationInProgress()) {
            // If IME is currently animating away, skip back gesture
            return;
        }
        mIsPreCommitAnimationInProgress = true;
        if (mWindowInsetsAnimationController != null) {
            // There's still an active animation controller. This means that a cancel post commit
            // animation of an earlier back gesture is still in progress. Let's cancel it and let
            // the new gesture seamlessly take over.
            resetPostCommitAnimator();
            setPreCommitProgress(0f);
            return;
        }
        mInsetsController.controlWindowInsetsAnimation(ime(), /*cancellationSignal*/ null,
                new WindowInsetsAnimationControlListener() {
                    @Override
                    public void onReady(@NonNull WindowInsetsAnimationController controller,
                            @WindowInsets.Type.InsetsType int types) {
                        mWindowInsetsAnimationController = controller;
                        if (mIsPreCommitAnimationInProgress) {
                            setPreCommitProgress(mLastProgress);
                        } else {
                            // gesture has already finished before IME became ready to animate
                            startPostCommitAnim(mTriggerBack);
                        }
                    }

                    @Override
                    public void onFinished(@NonNull WindowInsetsAnimationController controller) {
                        reset();
                    }

                    @Override
                    public void onCancelled(@Nullable WindowInsetsAnimationController controller) {
                        reset();
                    }
                }, /*fromIme*/ false, /*durationMs*/ -1, /*interpolator*/ null, ANIMATION_TYPE_USER,
                /*fromPredictiveBack*/ true);
    }

    @Override
    public void onBackProgressed(@NonNull BackEvent backEvent) {
        mLastProgress = backEvent.getProgress();
        setPreCommitProgress(mLastProgress);
    }

    @Override
    public void onBackCancelled() {
        if (isAdjustResize()) return;
        startPostCommitAnim(/*hideIme*/ false);
    }

    @Override
    public void onBackInvoked() {
        if (isAdjustResize()) {
            mInsetsController.hide(ime());
            return;
        }
        startPostCommitAnim(/*hideIme*/ true);
    }

    private void setPreCommitProgress(float progress) {
        if (isHideAnimationInProgress()) return;
        if (mWindowInsetsAnimationController != null) {
            float hiddenY = mWindowInsetsAnimationController.getHiddenStateInsets().bottom;
            float shownY = mWindowInsetsAnimationController.getShownStateInsets().bottom;
            float imeHeight = shownY - hiddenY;
            float interpolatedProgress = STANDARD_DECELERATE.getInterpolation(progress);
            int newY = (int) (imeHeight - interpolatedProgress * (imeHeight * PEEK_FRACTION));
            mWindowInsetsAnimationController.setInsetsAndAlpha(Insets.of(0, 0, 0, newY), 1f,
                    progress);
        }
    }

    private void startPostCommitAnim(boolean triggerBack) {
        mIsPreCommitAnimationInProgress = false;
        if (mWindowInsetsAnimationController == null || isHideAnimationInProgress()) {
            mTriggerBack = triggerBack;
            return;
        }
        mTriggerBack = triggerBack;
        int currentBottomInset = mWindowInsetsAnimationController.getCurrentInsets().bottom;
        int targetBottomInset;
        if (triggerBack) {
            targetBottomInset = mWindowInsetsAnimationController.getHiddenStateInsets().bottom;
        } else {
            targetBottomInset = mWindowInsetsAnimationController.getShownStateInsets().bottom;
        }
        mPostCommitAnimator = ValueAnimator.ofFloat(currentBottomInset, targetBottomInset);
        mPostCommitAnimator.setInterpolator(
                triggerBack ? STANDARD_ACCELERATE : EMPHASIZED_DECELERATE);
        mPostCommitAnimator.addUpdateListener(animation -> {
            int bottomInset = (int) ((float) animation.getAnimatedValue());
            if (mWindowInsetsAnimationController != null) {
                mWindowInsetsAnimationController.setInsetsAndAlpha(Insets.of(0, 0, 0, bottomInset),
                        1f, animation.getAnimatedFraction());
            } else {
                reset();
            }
        });
        mPostCommitAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animator) {
                if (mIsPreCommitAnimationInProgress) {
                    // this means a new gesture has started while the cancel-post-commit-animation
                    // was in progress. Let's not reset anything and let the new user gesture take
                    // over seamlessly
                    return;
                }
                if (mWindowInsetsAnimationController != null) {
                    mWindowInsetsAnimationController.finish(!triggerBack);
                }
                reset();
            }
        });
        mPostCommitAnimator.setDuration(
                triggerBack ? POST_COMMIT_DURATION_MS : POST_COMMIT_CANCEL_DURATION_MS);
        mPostCommitAnimator.start();
    }

    private void reset() {
        mWindowInsetsAnimationController = null;
        resetPostCommitAnimator();
        mLastProgress = 0f;
        mTriggerBack = false;
        mIsPreCommitAnimationInProgress = false;
    }

    private void resetPostCommitAnimator() {
        if (mPostCommitAnimator != null) {
            mPostCommitAnimator.cancel();
            mPostCommitAnimator = null;
        }
    }

    private boolean isAdjustResize() {
        return (mViewRoot.mWindowAttributes.softInputMode & SOFT_INPUT_MASK_ADJUST)
                == SOFT_INPUT_ADJUST_RESIZE;
    }

    private boolean isHideAnimationInProgress() {
        return mPostCommitAnimator != null && mTriggerBack;
    }

}
+17 −8
Original line number Diff line number Diff line
@@ -29,6 +29,8 @@ import static android.view.WindowInsets.Type.all;
import static android.view.WindowInsets.Type.captionBar;
import static android.view.WindowInsets.Type.ime;

import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE;

import android.animation.AnimationHandler;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
@@ -322,7 +324,7 @@ public class InsetsController implements WindowInsetsController, InsetsAnimation
    public static final int ANIMATION_TYPE_HIDE = 1;

    /** Running animation is controlled by user via {@link #controlWindowInsetsAnimation} */
    @VisibleForTesting
    @VisibleForTesting(visibility = PACKAGE)
    public static final int ANIMATION_TYPE_USER = 2;

    /** Running animation will resize insets */
@@ -1076,7 +1078,7 @@ public class InsetsController implements WindowInsetsController, InsetsAnimation
        show(types, false /* fromIme */, null /* statsToken */);
    }

    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
    @VisibleForTesting(visibility = PACKAGE)
    public void show(@InsetsType int types, boolean fromIme,
            @Nullable ImeTracker.Token statsToken) {
        if ((types & ime()) != 0) {
@@ -1260,14 +1262,15 @@ public class InsetsController implements WindowInsetsController, InsetsAnimation
            @Nullable CancellationSignal cancellationSignal,
            @NonNull WindowInsetsAnimationControlListener listener) {
        controlWindowInsetsAnimation(types, cancellationSignal, listener,
                false /* fromIme */, durationMillis, interpolator, ANIMATION_TYPE_USER);
                false /* fromIme */, durationMillis, interpolator, ANIMATION_TYPE_USER,
                false /* fromPredictiveBack */);
    }

    private void controlWindowInsetsAnimation(@InsetsType int types,
    void controlWindowInsetsAnimation(@InsetsType int types,
            @Nullable CancellationSignal cancellationSignal,
            WindowInsetsAnimationControlListener listener,
            boolean fromIme, long durationMs, @Nullable Interpolator interpolator,
            @AnimationType int animationType) {
            @AnimationType int animationType, boolean fromPredictiveBack) {
        if ((mState.calculateUncontrollableInsetsFromFrame(mFrame) & types) != 0) {
            listener.onCancelled(null);
            return;
@@ -1279,7 +1282,8 @@ public class InsetsController implements WindowInsetsController, InsetsAnimation
        }

        controlAnimationUnchecked(types, cancellationSignal, listener, mFrame, fromIme, durationMs,
                interpolator, animationType, getLayoutInsetsDuringAnimationMode(types),
                interpolator, animationType,
                getLayoutInsetsDuringAnimationMode(types, fromPredictiveBack),
                false /* useInsetsAnimationThread */, null /* statsToken */);
    }

@@ -1526,7 +1530,12 @@ public class InsetsController implements WindowInsetsController, InsetsAnimation
    }

    private @LayoutInsetsDuringAnimation int getLayoutInsetsDuringAnimationMode(
            @InsetsType int types) {
            @InsetsType int types, boolean fromPredictiveBack) {
        if (fromPredictiveBack) {
            // When insets are animated by predictive back, we want insets to be shown to prevent a
            // jump cut from shown to hidden at the start of the predictive back animation
            return LAYOUT_INSETS_DURING_ANIMATION_SHOWN;
        }
        // Generally, we want to layout the opposite of the current state. This is to make animation
        // callbacks easy to use: The can capture the layout values and then treat that as end-state
        // during the animation.
@@ -1730,7 +1739,7 @@ public class InsetsController implements WindowInsetsController, InsetsAnimation
        return ANIMATION_TYPE_NONE;
    }

    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
    @VisibleForTesting(visibility = PACKAGE)
    public void setRequestedVisibleTypes(@InsetsType int visibleTypes, @InsetsType int mask) {
        final @InsetsType int requestedVisibleTypes =
                (mRequestedVisibleTypes & ~mask) | (visibleTypes & mask);
+6 −2
Original line number Diff line number Diff line
@@ -114,6 +114,7 @@ import static android.view.inputmethod.InputMethodEditorTraceProto.InputMethodCl
import static android.view.inputmethod.InputMethodEditorTraceProto.InputMethodClientsTraceProto.ClientSideProto.INSETS_CONTROLLER;
import static com.android.input.flags.Flags.enablePointerChoreographer;
import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE;
import static com.android.window.flags.Flags.activityWindowInfoFlag;
import static com.android.window.flags.Flags.enableBufferTransformHintFromDisplay;
import static com.android.window.flags.Flags.setScPropertiesInClient;
@@ -941,6 +942,7 @@ public final class ViewRootImpl implements ViewParent,
                    new InputEventConsistencyVerifier(this, 0) : null;
    private final InsetsController mInsetsController;
    private final ImeBackAnimationController mImeBackAnimationController;
    private final ImeFocusController mImeFocusController;
    private boolean mIsSurfaceOpaque;
@@ -1207,6 +1209,7 @@ public final class ViewRootImpl implements ViewParent,
        // TODO(b/222696368): remove getSfInstance usage and use vsyncId for transactions
        mChoreographer = Choreographer.getInstance();
        mInsetsController = new InsetsController(new ViewRootInsetsControllerHost(this));
        mImeBackAnimationController = new ImeBackAnimationController(this);
        mHandwritingInitiator = new HandwritingInitiator(
                mViewConfiguration,
                mContext.getSystemService(InputMethodManager.class));
@@ -3182,7 +3185,7 @@ public final class ViewRootImpl implements ViewParent,
                        == LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
    }
    @VisibleForTesting
    @VisibleForTesting(visibility = PACKAGE)
    public InsetsController getInsetsController() {
        return mInsetsController;
    }
@@ -12149,7 +12152,8 @@ public final class ViewRootImpl implements ViewParent,
                            + "IWindow:%s Session:%s",
                    mOnBackInvokedDispatcher, mBasePackageName, mWindow, mWindowSession));
        }
        mOnBackInvokedDispatcher.attachToWindow(mWindowSession, mWindow);
        mOnBackInvokedDispatcher.attachToWindow(mWindowSession, mWindow,
                mImeBackAnimationController);
    }
    private void sendBackKeyEvent(int action) {
+22 −2
Original line number Diff line number Diff line
@@ -148,8 +148,17 @@ public class ImeOnBackInvokedDispatcher implements OnBackInvokedDispatcher, Parc
            @OnBackInvokedDispatcher.Priority int priority,
            int callbackId,
            @NonNull WindowOnBackInvokedDispatcher receivingDispatcher) {
        final ImeOnBackInvokedCallback imeCallback =
                new ImeOnBackInvokedCallback(iCallback, callbackId, priority);
        final ImeOnBackInvokedCallback imeCallback;
        if (priority == PRIORITY_SYSTEM) {
            // A callback registration with PRIORITY_SYSTEM indicates that a predictive back
            // animation can be played on the IME. Therefore register the
            // DefaultImeOnBackInvokedCallback with the receiving dispatcher and override the
            // priority to PRIORITY_DEFAULT.
            priority = PRIORITY_DEFAULT;
            imeCallback = new DefaultImeOnBackAnimationCallback(iCallback, callbackId, priority);
        } else {
            imeCallback = new ImeOnBackInvokedCallback(iCallback, callbackId, priority);
        }
        mImeCallbacks.add(imeCallback);
        receivingDispatcher.registerOnBackInvokedCallbackUnchecked(imeCallback, priority);
    }
@@ -229,6 +238,17 @@ public class ImeOnBackInvokedDispatcher implements OnBackInvokedDispatcher, Parc
        }
    }

    /**
     * Subclass of ImeOnBackInvokedCallback indicating that a predictive IME back animation may be
     * played instead of invoking the callback.
     */
    static class DefaultImeOnBackAnimationCallback extends ImeOnBackInvokedCallback {
        DefaultImeOnBackAnimationCallback(@NonNull IOnBackInvokedCallback iCallback, int id,
                int priority) {
            super(iCallback, id, priority);
        }
    }

    /**
     * Transfers {@link ImeOnBackInvokedCallback}s registered on one {@link ViewRootImpl} to
     * another {@link ViewRootImpl} on focus change.
Loading