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