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