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