1 /*
2  * Copyright (C) 2022 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.inputmethodservice.navigationbar;
18 
19 import android.animation.Animator;
20 import android.animation.AnimatorListenerAdapter;
21 import android.animation.ObjectAnimator;
22 import android.annotation.DimenRes;
23 import android.content.Context;
24 import android.graphics.Canvas;
25 import android.graphics.CanvasProperty;
26 import android.graphics.ColorFilter;
27 import android.graphics.Paint;
28 import android.graphics.PixelFormat;
29 import android.graphics.RecordingCanvas;
30 import android.graphics.drawable.Drawable;
31 import android.os.Handler;
32 import android.os.Trace;
33 import android.view.RenderNodeAnimator;
34 import android.view.View;
35 import android.view.ViewConfiguration;
36 import android.view.animation.Interpolator;
37 import android.view.animation.PathInterpolator;
38 
39 import java.util.ArrayList;
40 import java.util.HashSet;
41 
42 final class KeyButtonRipple extends Drawable {
43 
44     private static final float GLOW_MAX_SCALE_FACTOR = 1.35f;
45     private static final float GLOW_MAX_ALPHA = 0.2f;
46     private static final float GLOW_MAX_ALPHA_DARK = 0.1f;
47     private static final int ANIMATION_DURATION_SCALE = 350;
48     private static final int ANIMATION_DURATION_FADE = 450;
49     private static final Interpolator ALPHA_OUT_INTERPOLATOR =
50             new PathInterpolator(0f, 0f, 0.8f, 1f);
51 
52     @DimenRes
53     private final int mMaxWidthResource;
54 
55     private Paint mRipplePaint;
56     private CanvasProperty<Float> mLeftProp;
57     private CanvasProperty<Float> mTopProp;
58     private CanvasProperty<Float> mRightProp;
59     private CanvasProperty<Float> mBottomProp;
60     private CanvasProperty<Float> mRxProp;
61     private CanvasProperty<Float> mRyProp;
62     private CanvasProperty<Paint> mPaintProp;
63     private float mGlowAlpha = 0f;
64     private float mGlowScale = 1f;
65     private boolean mPressed;
66     private boolean mVisible;
67     private boolean mDrawingHardwareGlow;
68     private int mMaxWidth;
69     private boolean mLastDark;
70     private boolean mDark;
71     private boolean mDelayTouchFeedback;
72 
73     private final Interpolator mInterpolator = new LogInterpolator();
74     private boolean mSupportHardware;
75     private final View mTargetView;
76     private final Handler mHandler = new Handler();
77 
78     private final HashSet<Animator> mRunningAnimations = new HashSet<>();
79     private final ArrayList<Animator> mTmpArray = new ArrayList<>();
80 
81     private final TraceAnimatorListener mExitHwTraceAnimator =
82             new TraceAnimatorListener("exitHardware");
83     private final TraceAnimatorListener mEnterHwTraceAnimator =
84             new TraceAnimatorListener("enterHardware");
85 
86     public enum Type {
87         OVAL,
88         ROUNDED_RECT
89     }
90 
91     private Type mType = Type.ROUNDED_RECT;
92 
KeyButtonRipple(Context ctx, View targetView, @DimenRes int maxWidthResource)93     KeyButtonRipple(Context ctx, View targetView, @DimenRes int maxWidthResource) {
94         mMaxWidthResource = maxWidthResource;
95         mMaxWidth = ctx.getResources().getDimensionPixelSize(maxWidthResource);
96         mTargetView = targetView;
97     }
98 
updateResources()99     public void updateResources() {
100         mMaxWidth = mTargetView.getContext().getResources()
101                 .getDimensionPixelSize(mMaxWidthResource);
102         invalidateSelf();
103     }
104 
setDarkIntensity(float darkIntensity)105     public void setDarkIntensity(float darkIntensity) {
106         mDark = darkIntensity >= 0.5f;
107     }
108 
setDelayTouchFeedback(boolean delay)109     public void setDelayTouchFeedback(boolean delay) {
110         mDelayTouchFeedback = delay;
111     }
112 
setType(Type type)113     public void setType(Type type) {
114         mType = type;
115     }
116 
getRipplePaint()117     private Paint getRipplePaint() {
118         if (mRipplePaint == null) {
119             mRipplePaint = new Paint();
120             mRipplePaint.setAntiAlias(true);
121             mRipplePaint.setColor(mLastDark ? 0xff000000 : 0xffffffff);
122         }
123         return mRipplePaint;
124     }
125 
drawSoftware(Canvas canvas)126     private void drawSoftware(Canvas canvas) {
127         if (mGlowAlpha > 0f) {
128             final Paint p = getRipplePaint();
129             p.setAlpha((int) (mGlowAlpha * 255f));
130 
131             final float w = getBounds().width();
132             final float h = getBounds().height();
133             final boolean horizontal = w > h;
134             final float diameter = getRippleSize() * mGlowScale;
135             final float radius = diameter * .5f;
136             final float cx = w * .5f;
137             final float cy = h * .5f;
138             final float rx = horizontal ? radius : cx;
139             final float ry = horizontal ? cy : radius;
140             final float corner = horizontal ? cy : cx;
141 
142             if (mType == Type.ROUNDED_RECT) {
143                 canvas.drawRoundRect(cx - rx, cy - ry, cx + rx, cy + ry, corner, corner, p);
144             } else {
145                 canvas.save();
146                 canvas.translate(cx, cy);
147                 float r = Math.min(rx, ry);
148                 canvas.drawOval(-r, -r, r, r, p);
149                 canvas.restore();
150             }
151         }
152     }
153 
154     @Override
draw(Canvas canvas)155     public void draw(Canvas canvas) {
156         mSupportHardware = canvas.isHardwareAccelerated();
157         if (mSupportHardware) {
158             drawHardware((RecordingCanvas) canvas);
159         } else {
160             drawSoftware(canvas);
161         }
162     }
163 
164     @Override
setAlpha(int alpha)165     public void setAlpha(int alpha) {
166         // Not supported.
167     }
168 
169     @Override
setColorFilter(ColorFilter colorFilter)170     public void setColorFilter(ColorFilter colorFilter) {
171         // Not supported.
172     }
173 
174     @Override
getOpacity()175     public int getOpacity() {
176         return PixelFormat.TRANSLUCENT;
177     }
178 
isHorizontal()179     private boolean isHorizontal() {
180         return getBounds().width() > getBounds().height();
181     }
182 
drawHardware(RecordingCanvas c)183     private void drawHardware(RecordingCanvas c) {
184         if (mDrawingHardwareGlow) {
185             if (mType == Type.ROUNDED_RECT) {
186                 c.drawRoundRect(mLeftProp, mTopProp, mRightProp, mBottomProp, mRxProp, mRyProp,
187                         mPaintProp);
188             } else {
189                 CanvasProperty<Float> cx = CanvasProperty.createFloat(getBounds().width() / 2);
190                 CanvasProperty<Float> cy = CanvasProperty.createFloat(getBounds().height() / 2);
191                 int d = Math.min(getBounds().width(), getBounds().height());
192                 CanvasProperty<Float> r = CanvasProperty.createFloat(1.0f * d / 2);
193                 c.drawCircle(cx, cy, r, mPaintProp);
194             }
195         }
196     }
197 
198     /** Gets the glow alpha, used by {@link android.animation.ObjectAnimator} via reflection. */
getGlowAlpha()199     public float getGlowAlpha() {
200         return mGlowAlpha;
201     }
202 
203     /** Sets the glow alpha, used by {@link android.animation.ObjectAnimator} via reflection. */
setGlowAlpha(float x)204     public void setGlowAlpha(float x) {
205         mGlowAlpha = x;
206         invalidateSelf();
207     }
208 
209     /** Gets the glow scale, used by {@link android.animation.ObjectAnimator} via reflection. */
getGlowScale()210     public float getGlowScale() {
211         return mGlowScale;
212     }
213 
214     /** Sets the glow scale, used by {@link android.animation.ObjectAnimator} via reflection. */
setGlowScale(float x)215     public void setGlowScale(float x) {
216         mGlowScale = x;
217         invalidateSelf();
218     }
219 
getMaxGlowAlpha()220     private float getMaxGlowAlpha() {
221         return mLastDark ? GLOW_MAX_ALPHA_DARK : GLOW_MAX_ALPHA;
222     }
223 
224     @Override
onStateChange(int[] state)225     protected boolean onStateChange(int[] state) {
226         boolean pressed = false;
227         for (int i = 0; i < state.length; i++) {
228             if (state[i] == android.R.attr.state_pressed) {
229                 pressed = true;
230                 break;
231             }
232         }
233         if (pressed != mPressed) {
234             setPressed(pressed);
235             mPressed = pressed;
236             return true;
237         } else {
238             return false;
239         }
240     }
241 
242     @Override
setVisible(boolean visible, boolean restart)243     public boolean setVisible(boolean visible, boolean restart) {
244         boolean changed = super.setVisible(visible, restart);
245         if (changed) {
246             // End any existing animations when the visibility changes
247             jumpToCurrentState();
248         }
249         return changed;
250     }
251 
252     @Override
jumpToCurrentState()253     public void jumpToCurrentState() {
254         endAnimations("jumpToCurrentState", false /* cancel */);
255     }
256 
257     @Override
isStateful()258     public boolean isStateful() {
259         return true;
260     }
261 
262     @Override
hasFocusStateSpecified()263     public boolean hasFocusStateSpecified() {
264         return true;
265     }
266 
setPressed(boolean pressed)267     public void setPressed(boolean pressed) {
268         if (mDark != mLastDark && pressed) {
269             mRipplePaint = null;
270             mLastDark = mDark;
271         }
272         if (mSupportHardware) {
273             setPressedHardware(pressed);
274         } else {
275             setPressedSoftware(pressed);
276         }
277     }
278 
279     /**
280      * Abort the ripple while it is delayed and before shown used only when setShouldDelayStartTouch
281      * is enabled.
282      */
abortDelayedRipple()283     public void abortDelayedRipple() {
284         mHandler.removeCallbacksAndMessages(null);
285     }
286 
endAnimations(String reason, boolean cancel)287     private void endAnimations(String reason, boolean cancel) {
288         Trace.beginSection("KeyButtonRipple.endAnim: reason=" + reason + " cancel=" + cancel);
289         Trace.endSection();
290         mVisible = false;
291         mTmpArray.addAll(mRunningAnimations);
292         int size = mTmpArray.size();
293         for (int i = 0; i < size; i++) {
294             Animator a = mTmpArray.get(i);
295             if (cancel) {
296                 a.cancel();
297             } else {
298                 a.end();
299             }
300         }
301         mTmpArray.clear();
302         mRunningAnimations.clear();
303         mHandler.removeCallbacksAndMessages(null);
304     }
305 
setPressedSoftware(boolean pressed)306     private void setPressedSoftware(boolean pressed) {
307         if (pressed) {
308             if (mDelayTouchFeedback) {
309                 if (mRunningAnimations.isEmpty()) {
310                     mHandler.removeCallbacksAndMessages(null);
311                     mHandler.postDelayed(this::enterSoftware, ViewConfiguration.getTapTimeout());
312                 } else if (mVisible) {
313                     enterSoftware();
314                 }
315             } else {
316                 enterSoftware();
317             }
318         } else {
319             exitSoftware();
320         }
321     }
322 
enterSoftware()323     private void enterSoftware() {
324         endAnimations("enterSoftware", true /* cancel */);
325         mVisible = true;
326         mGlowAlpha = getMaxGlowAlpha();
327         ObjectAnimator scaleAnimator = ObjectAnimator.ofFloat(this, "glowScale",
328                 0f, GLOW_MAX_SCALE_FACTOR);
329         scaleAnimator.setInterpolator(mInterpolator);
330         scaleAnimator.setDuration(ANIMATION_DURATION_SCALE);
331         scaleAnimator.addListener(mAnimatorListener);
332         scaleAnimator.start();
333         mRunningAnimations.add(scaleAnimator);
334 
335         // With the delay, it could eventually animate the enter animation with no pressed state,
336         // then immediately show the exit animation. If this is skipped there will be no ripple.
337         if (mDelayTouchFeedback && !mPressed) {
338             exitSoftware();
339         }
340     }
341 
exitSoftware()342     private void exitSoftware() {
343         ObjectAnimator alphaAnimator = ObjectAnimator.ofFloat(this, "glowAlpha", mGlowAlpha, 0f);
344         alphaAnimator.setInterpolator(ALPHA_OUT_INTERPOLATOR);
345         alphaAnimator.setDuration(ANIMATION_DURATION_FADE);
346         alphaAnimator.addListener(mAnimatorListener);
347         alphaAnimator.start();
348         mRunningAnimations.add(alphaAnimator);
349     }
350 
setPressedHardware(boolean pressed)351     private void setPressedHardware(boolean pressed) {
352         if (pressed) {
353             if (mDelayTouchFeedback) {
354                 if (mRunningAnimations.isEmpty()) {
355                     mHandler.removeCallbacksAndMessages(null);
356                     mHandler.postDelayed(this::enterHardware, ViewConfiguration.getTapTimeout());
357                 } else if (mVisible) {
358                     enterHardware();
359                 }
360             } else {
361                 enterHardware();
362             }
363         } else {
364             exitHardware();
365         }
366     }
367 
368     /**
369      * Sets the left/top property for the round rect to {@code prop} depending on whether we are
370      * horizontal or vertical mode.
371      */
setExtendStart(CanvasProperty<Float> prop)372     private void setExtendStart(CanvasProperty<Float> prop) {
373         if (isHorizontal()) {
374             mLeftProp = prop;
375         } else {
376             mTopProp = prop;
377         }
378     }
379 
getExtendStart()380     private CanvasProperty<Float> getExtendStart() {
381         return isHorizontal() ? mLeftProp : mTopProp;
382     }
383 
384     /**
385      * Sets the right/bottom property for the round rect to {@code prop} depending on whether we are
386      * horizontal or vertical mode.
387      */
setExtendEnd(CanvasProperty<Float> prop)388     private void setExtendEnd(CanvasProperty<Float> prop) {
389         if (isHorizontal()) {
390             mRightProp = prop;
391         } else {
392             mBottomProp = prop;
393         }
394     }
395 
getExtendEnd()396     private CanvasProperty<Float> getExtendEnd() {
397         return isHorizontal() ? mRightProp : mBottomProp;
398     }
399 
getExtendSize()400     private int getExtendSize() {
401         return isHorizontal() ? getBounds().width() : getBounds().height();
402     }
403 
getRippleSize()404     private int getRippleSize() {
405         int size = isHorizontal() ? getBounds().width() : getBounds().height();
406         return Math.min(size, mMaxWidth);
407     }
408 
enterHardware()409     private void enterHardware() {
410         endAnimations("enterHardware", true /* cancel */);
411         mVisible = true;
412         mDrawingHardwareGlow = true;
413         setExtendStart(CanvasProperty.createFloat(getExtendSize() / 2));
414         final RenderNodeAnimator startAnim = new RenderNodeAnimator(getExtendStart(),
415                 getExtendSize() / 2 - GLOW_MAX_SCALE_FACTOR * getRippleSize() / 2);
416         startAnim.setDuration(ANIMATION_DURATION_SCALE);
417         startAnim.setInterpolator(mInterpolator);
418         startAnim.addListener(mAnimatorListener);
419         startAnim.setTarget(mTargetView);
420 
421         setExtendEnd(CanvasProperty.createFloat(getExtendSize() / 2));
422         final RenderNodeAnimator endAnim = new RenderNodeAnimator(getExtendEnd(),
423                 getExtendSize() / 2 + GLOW_MAX_SCALE_FACTOR * getRippleSize() / 2);
424         endAnim.setDuration(ANIMATION_DURATION_SCALE);
425         endAnim.setInterpolator(mInterpolator);
426         endAnim.addListener(mAnimatorListener);
427         endAnim.addListener(mEnterHwTraceAnimator);
428         endAnim.setTarget(mTargetView);
429 
430         if (isHorizontal()) {
431             mTopProp = CanvasProperty.createFloat(0f);
432             mBottomProp = CanvasProperty.createFloat(getBounds().height());
433             mRxProp = CanvasProperty.createFloat(getBounds().height() / 2);
434             mRyProp = CanvasProperty.createFloat(getBounds().height() / 2);
435         } else {
436             mLeftProp = CanvasProperty.createFloat(0f);
437             mRightProp = CanvasProperty.createFloat(getBounds().width());
438             mRxProp = CanvasProperty.createFloat(getBounds().width() / 2);
439             mRyProp = CanvasProperty.createFloat(getBounds().width() / 2);
440         }
441 
442         mGlowScale = GLOW_MAX_SCALE_FACTOR;
443         mGlowAlpha = getMaxGlowAlpha();
444         mRipplePaint = getRipplePaint();
445         mRipplePaint.setAlpha((int) (mGlowAlpha * 255));
446         mPaintProp = CanvasProperty.createPaint(mRipplePaint);
447 
448         startAnim.start();
449         endAnim.start();
450         mRunningAnimations.add(startAnim);
451         mRunningAnimations.add(endAnim);
452 
453         invalidateSelf();
454 
455         // With the delay, it could eventually animate the enter animation with no pressed state,
456         // then immediately show the exit animation. If this is skipped there will be no ripple.
457         if (mDelayTouchFeedback && !mPressed) {
458             exitHardware();
459         }
460     }
461 
exitHardware()462     private void exitHardware() {
463         mPaintProp = CanvasProperty.createPaint(getRipplePaint());
464         final RenderNodeAnimator opacityAnim = new RenderNodeAnimator(mPaintProp,
465                 RenderNodeAnimator.PAINT_ALPHA, 0);
466         opacityAnim.setDuration(ANIMATION_DURATION_FADE);
467         opacityAnim.setInterpolator(ALPHA_OUT_INTERPOLATOR);
468         opacityAnim.addListener(mAnimatorListener);
469         opacityAnim.addListener(mExitHwTraceAnimator);
470         opacityAnim.setTarget(mTargetView);
471 
472         opacityAnim.start();
473         mRunningAnimations.add(opacityAnim);
474 
475         invalidateSelf();
476     }
477 
478     private final AnimatorListenerAdapter mAnimatorListener =
479             new AnimatorListenerAdapter() {
480                 @Override
481                 public void onAnimationEnd(Animator animation) {
482                     mRunningAnimations.remove(animation);
483                     if (mRunningAnimations.isEmpty() && !mPressed) {
484                         mVisible = false;
485                         mDrawingHardwareGlow = false;
486                         invalidateSelf();
487                     }
488                 }
489             };
490 
491     private static final class TraceAnimatorListener extends AnimatorListenerAdapter {
492         private final String mName;
TraceAnimatorListener(String name)493         TraceAnimatorListener(String name) {
494             mName = name;
495         }
496 
497         @Override
onAnimationStart(Animator animation)498         public void onAnimationStart(Animator animation) {
499             Trace.beginSection("KeyButtonRipple.start." + mName);
500             Trace.endSection();
501         }
502 
503         @Override
onAnimationCancel(Animator animation)504         public void onAnimationCancel(Animator animation) {
505             Trace.beginSection("KeyButtonRipple.cancel." + mName);
506             Trace.endSection();
507         }
508 
509         @Override
onAnimationEnd(Animator animation)510         public void onAnimationEnd(Animator animation) {
511             Trace.beginSection("KeyButtonRipple.end." + mName);
512             Trace.endSection();
513         }
514     }
515 
516     /**
517      * Interpolator with a smooth log deceleration
518      */
519     private static final class LogInterpolator implements Interpolator {
520         @Override
getInterpolation(float input)521         public float getInterpolation(float input) {
522             return 1 - (float) Math.pow(400, -input * 1.4);
523         }
524     }
525 }
526