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