1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.view;
18 
19 import static android.view.InsetsController.ANIMATION_TYPE_USER;
20 import static android.view.WindowInsets.Type.ime;
21 import static android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN;
22 import static android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
23 import static android.view.WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST;
24 
25 import android.animation.Animator;
26 import android.animation.AnimatorListenerAdapter;
27 import android.animation.ValueAnimator;
28 import android.annotation.NonNull;
29 import android.annotation.Nullable;
30 import android.graphics.Insets;
31 import android.util.Log;
32 import android.view.animation.BackGestureInterpolator;
33 import android.view.animation.Interpolator;
34 import android.view.animation.PathInterpolator;
35 import android.view.inputmethod.ImeTracker;
36 import android.window.BackEvent;
37 import android.window.OnBackAnimationCallback;
38 
39 import com.android.internal.inputmethod.SoftInputShowHideReason;
40 
41 import java.io.PrintWriter;
42 
43 /**
44  * Controller for IME predictive back animation
45  *
46  * @hide
47  */
48 public class ImeBackAnimationController implements OnBackAnimationCallback {
49 
50     private static final String TAG = "ImeBackAnimationController";
51     private static final int POST_COMMIT_DURATION_MS = 200;
52     private static final int POST_COMMIT_CANCEL_DURATION_MS = 50;
53     private static final float PEEK_FRACTION = 0.1f;
54     private static final Interpolator BACK_GESTURE = new BackGestureInterpolator();
55     private static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator(
56             0.05f, 0.7f, 0.1f, 1f);
57     private static final Interpolator STANDARD_ACCELERATE = new PathInterpolator(0.3f, 0f, 1f, 1f);
58 
59     private final InsetsController mInsetsController;
60     private final ViewRootImpl mViewRoot;
61     private WindowInsetsAnimationController mWindowInsetsAnimationController = null;
62     private ValueAnimator mPostCommitAnimator = null;
63     private float mLastProgress = 0f;
64     private boolean mTriggerBack = false;
65     private boolean mIsPreCommitAnimationInProgress = false;
66     private int mStartRootScrollY = 0;
67 
ImeBackAnimationController(ViewRootImpl viewRoot, InsetsController insetsController)68     public ImeBackAnimationController(ViewRootImpl viewRoot, InsetsController insetsController) {
69         mInsetsController = insetsController;
70         mViewRoot = viewRoot;
71     }
72 
73     @Override
onBackStarted(@onNull BackEvent backEvent)74     public void onBackStarted(@NonNull BackEvent backEvent) {
75         if (!isBackAnimationAllowed()) {
76             // There is no good solution for a predictive back animation if the app uses
77             // adjustResize, since we can't relayout the whole app for every frame. We also don't
78             // want to reveal any black areas behind the IME. Therefore let's not play any animation
79             // in that case for now.
80             Log.d(TAG, "onBackStarted -> not playing predictive back animation due to softinput"
81                     + " mode adjustResize AND no animation callback registered");
82             return;
83         }
84         if (isHideAnimationInProgress()) {
85             // If IME is currently animating away, skip back gesture
86             return;
87         }
88         mIsPreCommitAnimationInProgress = true;
89         if (mWindowInsetsAnimationController != null) {
90             // There's still an active animation controller. This means that a cancel post commit
91             // animation of an earlier back gesture is still in progress. Let's cancel it and let
92             // the new gesture seamlessly take over.
93             resetPostCommitAnimator();
94             setPreCommitProgress(0f);
95             return;
96         }
97         mInsetsController.controlWindowInsetsAnimation(ime(), /*cancellationSignal*/ null,
98                 new WindowInsetsAnimationControlListener() {
99                     @Override
100                     public void onReady(@NonNull WindowInsetsAnimationController controller,
101                             @WindowInsets.Type.InsetsType int types) {
102                         mWindowInsetsAnimationController = controller;
103                         if (isAdjustPan()) mStartRootScrollY = mViewRoot.mScrollY;
104                         if (mIsPreCommitAnimationInProgress) {
105                             setPreCommitProgress(mLastProgress);
106                         } else {
107                             // gesture has already finished before IME became ready to animate
108                             startPostCommitAnim(mTriggerBack);
109                         }
110                     }
111 
112                     @Override
113                     public void onFinished(@NonNull WindowInsetsAnimationController controller) {
114                         reset();
115                     }
116 
117                     @Override
118                     public void onCancelled(@Nullable WindowInsetsAnimationController controller) {
119                         reset();
120                     }
121                 }, /*fromIme*/ false, /*durationMs*/ -1, /*interpolator*/ null, ANIMATION_TYPE_USER,
122                 /*fromPredictiveBack*/ true);
123     }
124 
125     @Override
onBackProgressed(@onNull BackEvent backEvent)126     public void onBackProgressed(@NonNull BackEvent backEvent) {
127         mLastProgress = backEvent.getProgress();
128         setPreCommitProgress(mLastProgress);
129     }
130 
131     @Override
onBackCancelled()132     public void onBackCancelled() {
133         if (!isBackAnimationAllowed()) return;
134         startPostCommitAnim(/*hideIme*/ false);
135     }
136 
137     @Override
onBackInvoked()138     public void onBackInvoked() {
139         if (!isBackAnimationAllowed() || !mIsPreCommitAnimationInProgress) {
140             // play regular hide animation if back-animation is not allowed or if insets control has
141             // been cancelled by the system (this can happen in split screen for example)
142             mInsetsController.hide(ime());
143             return;
144         }
145         startPostCommitAnim(/*hideIme*/ true);
146     }
147 
setPreCommitProgress(float progress)148     private void setPreCommitProgress(float progress) {
149         if (isHideAnimationInProgress()) return;
150         if (mWindowInsetsAnimationController != null) {
151             float hiddenY = mWindowInsetsAnimationController.getHiddenStateInsets().bottom;
152             float shownY = mWindowInsetsAnimationController.getShownStateInsets().bottom;
153             float imeHeight = shownY - hiddenY;
154             float interpolatedProgress = BACK_GESTURE.getInterpolation(progress);
155             int newY = (int) (imeHeight - interpolatedProgress * (imeHeight * PEEK_FRACTION));
156             if (mStartRootScrollY != 0) {
157                 mViewRoot.setScrollY(
158                         (int) (mStartRootScrollY * (1 - interpolatedProgress * PEEK_FRACTION)));
159             }
160             mWindowInsetsAnimationController.setInsetsAndAlpha(Insets.of(0, 0, 0, newY), 1f,
161                     progress);
162         }
163     }
164 
startPostCommitAnim(boolean triggerBack)165     private void startPostCommitAnim(boolean triggerBack) {
166         mIsPreCommitAnimationInProgress = false;
167         if (mWindowInsetsAnimationController == null || isHideAnimationInProgress()) {
168             mTriggerBack = triggerBack;
169             return;
170         }
171         mTriggerBack = triggerBack;
172         int currentBottomInset = mWindowInsetsAnimationController.getCurrentInsets().bottom;
173         int targetBottomInset;
174         if (triggerBack) {
175             targetBottomInset = mWindowInsetsAnimationController.getHiddenStateInsets().bottom;
176         } else {
177             targetBottomInset = mWindowInsetsAnimationController.getShownStateInsets().bottom;
178         }
179         mPostCommitAnimator = ValueAnimator.ofFloat(currentBottomInset, targetBottomInset);
180         mPostCommitAnimator.setInterpolator(
181                 triggerBack ? STANDARD_ACCELERATE : EMPHASIZED_DECELERATE);
182         mPostCommitAnimator.addUpdateListener(animation -> {
183             int bottomInset = (int) ((float) animation.getAnimatedValue());
184             if (mWindowInsetsAnimationController != null) {
185                 mWindowInsetsAnimationController.setInsetsAndAlpha(Insets.of(0, 0, 0, bottomInset),
186                         1f, animation.getAnimatedFraction());
187             } else {
188                 reset();
189             }
190         });
191         mPostCommitAnimator.addListener(new AnimatorListenerAdapter() {
192             @Override
193             public void onAnimationEnd(Animator animator) {
194                 if (mIsPreCommitAnimationInProgress) {
195                     // this means a new gesture has started while the cancel-post-commit-animation
196                     // was in progress. Let's not reset anything and let the new user gesture take
197                     // over seamlessly
198                     return;
199                 }
200                 if (mWindowInsetsAnimationController != null) {
201                     mWindowInsetsAnimationController.finish(!triggerBack);
202                 }
203                 reset();
204             }
205         });
206         mPostCommitAnimator.setDuration(
207                 triggerBack ? POST_COMMIT_DURATION_MS : POST_COMMIT_CANCEL_DURATION_MS);
208         mPostCommitAnimator.start();
209         if (triggerBack) {
210             mInsetsController.setPredictiveBackImeHideAnimInProgress(true);
211             notifyHideIme();
212         }
213         if (mStartRootScrollY != 0 && !triggerBack) {
214             // This causes RootView to update its scroll back to the panned position
215             mInsetsController.getHost().notifyInsetsChanged();
216         }
217     }
218 
notifyHideIme()219     private void notifyHideIme() {
220         ImeTracker.Token statsToken = ImeTracker.forLogging().onStart(ImeTracker.TYPE_HIDE,
221                 ImeTracker.ORIGIN_CLIENT,
222                 SoftInputShowHideReason.HIDE_SOFT_INPUT_REQUEST_HIDE_WITH_CONTROL, true);
223         // This notifies the IME that it is being hidden. In response, the IME will unregister the
224         // animation callback, such that new back gestures happening during the post-commit phase of
225         // the hide animation can already dispatch to a new callback.
226         // Note that the IME will call hide() in InsetsController. InsetsController will not animate
227         // that hide request if it sees that ImeBackAnimationController is already animating
228         // the IME away
229         mInsetsController.getHost().getInputMethodManager()
230                 .notifyImeHidden(mInsetsController.getHost().getWindowToken(), statsToken);
231 
232         // requesting IME as invisible during post-commit
233         mInsetsController.setRequestedVisibleTypes(0, ime());
234         // Changes the animation state. This also notifies RootView of changed insets, which causes
235         // it to reset its scrollY to 0f (animated) if it was panned
236         mInsetsController.onAnimationStateChanged(ime(), /*running*/ true);
237     }
238 
reset()239     private void reset() {
240         mWindowInsetsAnimationController = null;
241         resetPostCommitAnimator();
242         mLastProgress = 0f;
243         mTriggerBack = false;
244         mIsPreCommitAnimationInProgress = false;
245         mInsetsController.setPredictiveBackImeHideAnimInProgress(false);
246         mStartRootScrollY = 0;
247     }
248 
resetPostCommitAnimator()249     private void resetPostCommitAnimator() {
250         if (mPostCommitAnimator != null) {
251             mPostCommitAnimator.cancel();
252             mPostCommitAnimator = null;
253         }
254     }
255 
isBackAnimationAllowed()256     private boolean isBackAnimationAllowed() {
257         // back animation is allowed in all cases except when softInputMode is adjust_resize AND
258         // there is no app-registered WindowInsetsAnimationCallback AND edge-to-edge is not enabled.
259         return (mViewRoot.mWindowAttributes.softInputMode & SOFT_INPUT_MASK_ADJUST)
260                 != SOFT_INPUT_ADJUST_RESIZE
261                 || (mViewRoot.mView != null && mViewRoot.mView.hasWindowInsetsAnimationCallback())
262                 || mViewRoot.mAttachInfo.mContentOnApplyWindowInsetsListener == null;
263     }
264 
isAdjustPan()265     private boolean isAdjustPan() {
266         return (mViewRoot.mWindowAttributes.softInputMode & SOFT_INPUT_MASK_ADJUST)
267                 == SOFT_INPUT_ADJUST_PAN;
268     }
269 
isHideAnimationInProgress()270     private boolean isHideAnimationInProgress() {
271         return mPostCommitAnimator != null && mTriggerBack;
272     }
273 
274     /**
275      * Dump information about this ImeBackAnimationController
276      *
277      * @param prefix the prefix that will be prepended to each line of the produced output
278      * @param writer the writer that will receive the resulting text
279      */
dump(String prefix, PrintWriter writer)280     public void dump(String prefix, PrintWriter writer) {
281         final String innerPrefix = prefix + "    ";
282         writer.println(prefix + "ImeBackAnimationController:");
283         writer.println(innerPrefix + "mLastProgress=" + mLastProgress);
284         writer.println(innerPrefix + "mTriggerBack=" + mTriggerBack);
285         writer.println(innerPrefix + "mIsPreCommitAnimationInProgress="
286                 + mIsPreCommitAnimationInProgress);
287         writer.println(innerPrefix + "mStartRootScrollY=" + mStartRootScrollY);
288         writer.println(innerPrefix + "isBackAnimationAllowed=" + isBackAnimationAllowed());
289         writer.println(innerPrefix + "isAdjustPan=" + isAdjustPan());
290         writer.println(innerPrefix + "isHideAnimationInProgress="
291                 + isHideAnimationInProgress());
292     }
293 
294 }
295