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