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