/* * 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_DURATION_SYNC_IME_MS; import static android.view.InsetsController.ANIMATION_DURATION_UNSYNC_IME_MS; import static android.view.InsetsController.ANIMATION_TYPE_USER; import static android.view.InsetsController.FAST_OUT_LINEAR_IN_INTERPOLATOR; import static android.view.InsetsController.SYNC_IME_INTERPOLATOR; import static android.view.WindowInsets.Type.ime; import static android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN; 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.app.WindowConfiguration; import android.graphics.Insets; import android.util.Log; import android.view.animation.BackGestureInterpolator; import android.view.animation.Interpolator; import android.view.animation.PathInterpolator; import android.view.inputmethod.Flags; import android.view.inputmethod.ImeTracker; import android.window.BackEvent; import android.window.OnBackAnimationCallback; import com.android.internal.inputmethod.SoftInputShowHideReason; import java.io.PrintWriter; /** * 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 BACK_GESTURE = new BackGestureInterpolator(); private static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator( 0.05f, 0.7f, 0.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; private int mStartRootScrollY = 0; public ImeBackAnimationController(ViewRootImpl viewRoot, InsetsController insetsController) { mInsetsController = insetsController; mViewRoot = viewRoot; } @Override public void onBackStarted(@NonNull BackEvent backEvent) { if (!isBackAnimationAllowed()) { // 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 AND no animation callback registered"); 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 (isAdjustPan()) mStartRootScrollY = mViewRoot.mScrollY; 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 (!isBackAnimationAllowed()) return; startPostCommitAnim(/*hideIme*/ false); } @Override public void onBackInvoked() { if (!isBackAnimationAllowed() || !mIsPreCommitAnimationInProgress) { // play regular hide animation if predictive back-animation is not allowed or if insets // control has been cancelled by the system. This can happen in multi-window mode for // example (i.e. split-screen or activity-embedding) notifyHideIme(); } else { startPostCommitAnim(/*hideIme*/ true); } if (Flags.refactorInsetsController()) { // Unregister all IME back callbacks so that back events are sent to the next callback // even while the hide animation is playing mInsetsController.getHost().getInputMethodManager().getImeOnBackInvokedDispatcher() .preliminaryClear(); } } private void setPreCommitProgress(float progress) { if (isHideAnimationInProgress()) return; setInterpolatedProgress(BACK_GESTURE.getInterpolation(progress) * PEEK_FRACTION); } private void setInterpolatedProgress(float progress) { if (mWindowInsetsAnimationController != null) { float hiddenY = mWindowInsetsAnimationController.getHiddenStateInsets().bottom; float shownY = mWindowInsetsAnimationController.getShownStateInsets().bottom; float imeHeight = shownY - hiddenY; int newY = (int) (imeHeight - progress * imeHeight); if (mStartRootScrollY != 0) { mViewRoot.setScrollY((int) (mStartRootScrollY * (1 - progress))); } 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; float targetProgress = triggerBack ? 1f : 0f; mPostCommitAnimator = ValueAnimator.ofFloat( BACK_GESTURE.getInterpolation(mLastProgress) * PEEK_FRACTION, targetProgress); Interpolator interpolator; long duration; if (triggerBack && mViewRoot.mView.hasWindowInsetsAnimationCallback() && mWindowInsetsAnimationController.getShownStateInsets().bottom != 0) { interpolator = SYNC_IME_INTERPOLATOR; duration = ANIMATION_DURATION_SYNC_IME_MS; } else if (triggerBack) { interpolator = FAST_OUT_LINEAR_IN_INTERPOLATOR; duration = ANIMATION_DURATION_UNSYNC_IME_MS; } else { interpolator = EMPHASIZED_DECELERATE; duration = POST_COMMIT_CANCEL_DURATION_MS; } mPostCommitAnimator.setInterpolator(interpolator); mPostCommitAnimator.setDuration(duration); mPostCommitAnimator.addUpdateListener(animation -> { if (mWindowInsetsAnimationController != null) { setInterpolatedProgress((float) animation.getAnimatedValue()); } 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.start(); if (triggerBack) { mInsetsController.setPredictiveBackImeHideAnimInProgress(true); notifyHideIme(); // requesting IME as invisible during post-commit mInsetsController.setRequestedVisibleTypes(0, ime()); mInsetsController.onAnimationStateChanged(ime(), /*running*/ true); } } private void notifyHideIme() { ImeTracker.Token statsToken = ImeTracker.forLogging().onStart(ImeTracker.TYPE_HIDE, ImeTracker.ORIGIN_CLIENT, SoftInputShowHideReason.HIDE_SOFT_INPUT_REQUEST_HIDE_WITH_CONTROL, true); // This notifies the IME that it is being hidden. In response, the IME will unregister the // animation callback, such that new back gestures happening during the post-commit phase of // the hide animation can already dispatch to a new callback. // Note that the IME will call hide() in InsetsController. InsetsController will not animate // that hide request if it sees that ImeBackAnimationController is already animating // the IME away mInsetsController.getHost().getInputMethodManager() .notifyImeHidden(mInsetsController.getHost().getWindowToken(), statsToken); } private void reset() { mWindowInsetsAnimationController = null; resetPostCommitAnimator(); mLastProgress = 0f; mTriggerBack = false; mIsPreCommitAnimationInProgress = false; mInsetsController.setPredictiveBackImeHideAnimInProgress(false); mStartRootScrollY = 0; } private void resetPostCommitAnimator() { if (mPostCommitAnimator != null) { mPostCommitAnimator.cancel(); mPostCommitAnimator = null; } } private boolean isBackAnimationAllowed() { if (mViewRoot.mContext.getResources().getConfiguration().windowConfiguration .getWindowingMode() == WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW) { // TODO(b/346726115) enable predictive back animation in multi-window mode in // DisplayImeController return false; } // otherwise, the predictive back animation is allowed in all cases except when // 1. softInputMode is adjust_resize AND // 2. there is no app-registered WindowInsetsAnimationCallback AND // 3. edge-to-edge is not enabled. return (mViewRoot.mWindowAttributes.softInputMode & SOFT_INPUT_MASK_ADJUST) != SOFT_INPUT_ADJUST_RESIZE || (mViewRoot.mView != null && mViewRoot.mView.hasWindowInsetsAnimationCallback()) || mViewRoot.mAttachInfo.mContentOnApplyWindowInsetsListener == null; } private boolean isAdjustPan() { return (mViewRoot.mWindowAttributes.softInputMode & SOFT_INPUT_MASK_ADJUST) == SOFT_INPUT_ADJUST_PAN; } private boolean isHideAnimationInProgress() { return mPostCommitAnimator != null && mTriggerBack; } boolean isAnimationInProgress() { return mIsPreCommitAnimationInProgress || mWindowInsetsAnimationController != null; } /** * Dump information about this ImeBackAnimationController * * @param prefix the prefix that will be prepended to each line of the produced output * @param writer the writer that will receive the resulting text */ public void dump(String prefix, PrintWriter writer) { final String innerPrefix = prefix + " "; writer.println(prefix + "ImeBackAnimationController:"); writer.println(innerPrefix + "mLastProgress=" + mLastProgress); writer.println(innerPrefix + "mTriggerBack=" + mTriggerBack); writer.println(innerPrefix + "mIsPreCommitAnimationInProgress=" + mIsPreCommitAnimationInProgress); writer.println(innerPrefix + "mStartRootScrollY=" + mStartRootScrollY); writer.println(innerPrefix + "isBackAnimationAllowed=" + isBackAnimationAllowed()); writer.println(innerPrefix + "isAdjustPan=" + isAdjustPan()); writer.println(innerPrefix + "isHideAnimationInProgress=" + isHideAnimationInProgress()); } }