/* * Copyright (C) 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.inputmethodservice.navigationbar; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; import android.annotation.DimenRes; import android.content.Context; import android.graphics.Canvas; import android.graphics.CanvasProperty; import android.graphics.ColorFilter; import android.graphics.Paint; import android.graphics.PixelFormat; import android.graphics.RecordingCanvas; import android.graphics.drawable.Drawable; import android.os.Handler; import android.os.Trace; import android.view.RenderNodeAnimator; import android.view.View; import android.view.ViewConfiguration; import android.view.animation.Interpolator; import android.view.animation.PathInterpolator; import java.util.ArrayList; import java.util.HashSet; final class KeyButtonRipple extends Drawable { private static final float GLOW_MAX_SCALE_FACTOR = 1.35f; private static final float GLOW_MAX_ALPHA = 0.2f; private static final float GLOW_MAX_ALPHA_DARK = 0.1f; private static final int ANIMATION_DURATION_SCALE = 350; private static final int ANIMATION_DURATION_FADE = 450; private static final Interpolator ALPHA_OUT_INTERPOLATOR = new PathInterpolator(0f, 0f, 0.8f, 1f); @DimenRes private final int mMaxWidthResource; private Paint mRipplePaint; private CanvasProperty mLeftProp; private CanvasProperty mTopProp; private CanvasProperty mRightProp; private CanvasProperty mBottomProp; private CanvasProperty mRxProp; private CanvasProperty mRyProp; private CanvasProperty mPaintProp; private float mGlowAlpha = 0f; private float mGlowScale = 1f; private boolean mPressed; private boolean mVisible; private boolean mDrawingHardwareGlow; private int mMaxWidth; private boolean mLastDark; private boolean mDark; private boolean mDelayTouchFeedback; private final Interpolator mInterpolator = new LogInterpolator(); private boolean mSupportHardware; private final View mTargetView; private final Handler mHandler = new Handler(); private final HashSet mRunningAnimations = new HashSet<>(); private final ArrayList mTmpArray = new ArrayList<>(); private final TraceAnimatorListener mExitHwTraceAnimator = new TraceAnimatorListener("exitHardware"); private final TraceAnimatorListener mEnterHwTraceAnimator = new TraceAnimatorListener("enterHardware"); public enum Type { OVAL, ROUNDED_RECT } private Type mType = Type.ROUNDED_RECT; KeyButtonRipple(Context ctx, View targetView, @DimenRes int maxWidthResource) { mMaxWidthResource = maxWidthResource; mMaxWidth = ctx.getResources().getDimensionPixelSize(maxWidthResource); mTargetView = targetView; } public void updateResources() { mMaxWidth = mTargetView.getContext().getResources() .getDimensionPixelSize(mMaxWidthResource); invalidateSelf(); } public void setDarkIntensity(float darkIntensity) { mDark = darkIntensity >= 0.5f; } public void setDelayTouchFeedback(boolean delay) { mDelayTouchFeedback = delay; } public void setType(Type type) { mType = type; } private Paint getRipplePaint() { if (mRipplePaint == null) { mRipplePaint = new Paint(); mRipplePaint.setAntiAlias(true); mRipplePaint.setColor(mLastDark ? 0xff000000 : 0xffffffff); } return mRipplePaint; } private void drawSoftware(Canvas canvas) { if (mGlowAlpha > 0f) { final Paint p = getRipplePaint(); p.setAlpha((int) (mGlowAlpha * 255f)); final float w = getBounds().width(); final float h = getBounds().height(); final boolean horizontal = w > h; final float diameter = getRippleSize() * mGlowScale; final float radius = diameter * .5f; final float cx = w * .5f; final float cy = h * .5f; final float rx = horizontal ? radius : cx; final float ry = horizontal ? cy : radius; final float corner = horizontal ? cy : cx; if (mType == Type.ROUNDED_RECT) { canvas.drawRoundRect(cx - rx, cy - ry, cx + rx, cy + ry, corner, corner, p); } else { canvas.save(); canvas.translate(cx, cy); float r = Math.min(rx, ry); canvas.drawOval(-r, -r, r, r, p); canvas.restore(); } } } @Override public void draw(Canvas canvas) { mSupportHardware = canvas.isHardwareAccelerated(); if (mSupportHardware) { drawHardware((RecordingCanvas) canvas); } else { drawSoftware(canvas); } } @Override public void setAlpha(int alpha) { // Not supported. } @Override public void setColorFilter(ColorFilter colorFilter) { // Not supported. } @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } private boolean isHorizontal() { return getBounds().width() > getBounds().height(); } private void drawHardware(RecordingCanvas c) { if (mDrawingHardwareGlow) { if (mType == Type.ROUNDED_RECT) { c.drawRoundRect(mLeftProp, mTopProp, mRightProp, mBottomProp, mRxProp, mRyProp, mPaintProp); } else { CanvasProperty cx = CanvasProperty.createFloat(getBounds().width() / 2); CanvasProperty cy = CanvasProperty.createFloat(getBounds().height() / 2); int d = Math.min(getBounds().width(), getBounds().height()); CanvasProperty r = CanvasProperty.createFloat(1.0f * d / 2); c.drawCircle(cx, cy, r, mPaintProp); } } } /** Gets the glow alpha, used by {@link android.animation.ObjectAnimator} via reflection. */ public float getGlowAlpha() { return mGlowAlpha; } /** Sets the glow alpha, used by {@link android.animation.ObjectAnimator} via reflection. */ public void setGlowAlpha(float x) { mGlowAlpha = x; invalidateSelf(); } /** Gets the glow scale, used by {@link android.animation.ObjectAnimator} via reflection. */ public float getGlowScale() { return mGlowScale; } /** Sets the glow scale, used by {@link android.animation.ObjectAnimator} via reflection. */ public void setGlowScale(float x) { mGlowScale = x; invalidateSelf(); } private float getMaxGlowAlpha() { return mLastDark ? GLOW_MAX_ALPHA_DARK : GLOW_MAX_ALPHA; } @Override protected boolean onStateChange(int[] state) { boolean pressed = false; for (int i = 0; i < state.length; i++) { if (state[i] == android.R.attr.state_pressed) { pressed = true; break; } } if (pressed != mPressed) { setPressed(pressed); mPressed = pressed; return true; } else { return false; } } @Override public boolean setVisible(boolean visible, boolean restart) { boolean changed = super.setVisible(visible, restart); if (changed) { // End any existing animations when the visibility changes jumpToCurrentState(); } return changed; } @Override public void jumpToCurrentState() { endAnimations("jumpToCurrentState", false /* cancel */); } @Override public boolean isStateful() { return true; } @Override public boolean hasFocusStateSpecified() { return true; } public void setPressed(boolean pressed) { if (mDark != mLastDark && pressed) { mRipplePaint = null; mLastDark = mDark; } if (mSupportHardware) { setPressedHardware(pressed); } else { setPressedSoftware(pressed); } } /** * Abort the ripple while it is delayed and before shown used only when setShouldDelayStartTouch * is enabled. */ public void abortDelayedRipple() { mHandler.removeCallbacksAndMessages(null); } private void endAnimations(String reason, boolean cancel) { Trace.beginSection("KeyButtonRipple.endAnim: reason=" + reason + " cancel=" + cancel); Trace.endSection(); mVisible = false; mTmpArray.addAll(mRunningAnimations); int size = mTmpArray.size(); for (int i = 0; i < size; i++) { Animator a = mTmpArray.get(i); if (cancel) { a.cancel(); } else { a.end(); } } mTmpArray.clear(); mRunningAnimations.clear(); mHandler.removeCallbacksAndMessages(null); } private void setPressedSoftware(boolean pressed) { if (pressed) { if (mDelayTouchFeedback) { if (mRunningAnimations.isEmpty()) { mHandler.removeCallbacksAndMessages(null); mHandler.postDelayed(this::enterSoftware, ViewConfiguration.getTapTimeout()); } else if (mVisible) { enterSoftware(); } } else { enterSoftware(); } } else { exitSoftware(); } } private void enterSoftware() { endAnimations("enterSoftware", true /* cancel */); mVisible = true; mGlowAlpha = getMaxGlowAlpha(); ObjectAnimator scaleAnimator = ObjectAnimator.ofFloat(this, "glowScale", 0f, GLOW_MAX_SCALE_FACTOR); scaleAnimator.setInterpolator(mInterpolator); scaleAnimator.setDuration(ANIMATION_DURATION_SCALE); scaleAnimator.addListener(mAnimatorListener); scaleAnimator.start(); mRunningAnimations.add(scaleAnimator); // With the delay, it could eventually animate the enter animation with no pressed state, // then immediately show the exit animation. If this is skipped there will be no ripple. if (mDelayTouchFeedback && !mPressed) { exitSoftware(); } } private void exitSoftware() { ObjectAnimator alphaAnimator = ObjectAnimator.ofFloat(this, "glowAlpha", mGlowAlpha, 0f); alphaAnimator.setInterpolator(ALPHA_OUT_INTERPOLATOR); alphaAnimator.setDuration(ANIMATION_DURATION_FADE); alphaAnimator.addListener(mAnimatorListener); alphaAnimator.start(); mRunningAnimations.add(alphaAnimator); } private void setPressedHardware(boolean pressed) { if (pressed) { if (mDelayTouchFeedback) { if (mRunningAnimations.isEmpty()) { mHandler.removeCallbacksAndMessages(null); mHandler.postDelayed(this::enterHardware, ViewConfiguration.getTapTimeout()); } else if (mVisible) { enterHardware(); } } else { enterHardware(); } } else { exitHardware(); } } /** * Sets the left/top property for the round rect to {@code prop} depending on whether we are * horizontal or vertical mode. */ private void setExtendStart(CanvasProperty prop) { if (isHorizontal()) { mLeftProp = prop; } else { mTopProp = prop; } } private CanvasProperty getExtendStart() { return isHorizontal() ? mLeftProp : mTopProp; } /** * Sets the right/bottom property for the round rect to {@code prop} depending on whether we are * horizontal or vertical mode. */ private void setExtendEnd(CanvasProperty prop) { if (isHorizontal()) { mRightProp = prop; } else { mBottomProp = prop; } } private CanvasProperty getExtendEnd() { return isHorizontal() ? mRightProp : mBottomProp; } private int getExtendSize() { return isHorizontal() ? getBounds().width() : getBounds().height(); } private int getRippleSize() { int size = isHorizontal() ? getBounds().width() : getBounds().height(); return Math.min(size, mMaxWidth); } private void enterHardware() { endAnimations("enterHardware", true /* cancel */); mVisible = true; mDrawingHardwareGlow = true; setExtendStart(CanvasProperty.createFloat(getExtendSize() / 2)); final RenderNodeAnimator startAnim = new RenderNodeAnimator(getExtendStart(), getExtendSize() / 2 - GLOW_MAX_SCALE_FACTOR * getRippleSize() / 2); startAnim.setDuration(ANIMATION_DURATION_SCALE); startAnim.setInterpolator(mInterpolator); startAnim.addListener(mAnimatorListener); startAnim.setTarget(mTargetView); setExtendEnd(CanvasProperty.createFloat(getExtendSize() / 2)); final RenderNodeAnimator endAnim = new RenderNodeAnimator(getExtendEnd(), getExtendSize() / 2 + GLOW_MAX_SCALE_FACTOR * getRippleSize() / 2); endAnim.setDuration(ANIMATION_DURATION_SCALE); endAnim.setInterpolator(mInterpolator); endAnim.addListener(mAnimatorListener); endAnim.addListener(mEnterHwTraceAnimator); endAnim.setTarget(mTargetView); if (isHorizontal()) { mTopProp = CanvasProperty.createFloat(0f); mBottomProp = CanvasProperty.createFloat(getBounds().height()); mRxProp = CanvasProperty.createFloat(getBounds().height() / 2); mRyProp = CanvasProperty.createFloat(getBounds().height() / 2); } else { mLeftProp = CanvasProperty.createFloat(0f); mRightProp = CanvasProperty.createFloat(getBounds().width()); mRxProp = CanvasProperty.createFloat(getBounds().width() / 2); mRyProp = CanvasProperty.createFloat(getBounds().width() / 2); } mGlowScale = GLOW_MAX_SCALE_FACTOR; mGlowAlpha = getMaxGlowAlpha(); mRipplePaint = getRipplePaint(); mRipplePaint.setAlpha((int) (mGlowAlpha * 255)); mPaintProp = CanvasProperty.createPaint(mRipplePaint); startAnim.start(); endAnim.start(); mRunningAnimations.add(startAnim); mRunningAnimations.add(endAnim); invalidateSelf(); // With the delay, it could eventually animate the enter animation with no pressed state, // then immediately show the exit animation. If this is skipped there will be no ripple. if (mDelayTouchFeedback && !mPressed) { exitHardware(); } } private void exitHardware() { mPaintProp = CanvasProperty.createPaint(getRipplePaint()); final RenderNodeAnimator opacityAnim = new RenderNodeAnimator(mPaintProp, RenderNodeAnimator.PAINT_ALPHA, 0); opacityAnim.setDuration(ANIMATION_DURATION_FADE); opacityAnim.setInterpolator(ALPHA_OUT_INTERPOLATOR); opacityAnim.addListener(mAnimatorListener); opacityAnim.addListener(mExitHwTraceAnimator); opacityAnim.setTarget(mTargetView); opacityAnim.start(); mRunningAnimations.add(opacityAnim); invalidateSelf(); } private final AnimatorListenerAdapter mAnimatorListener = new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { mRunningAnimations.remove(animation); if (mRunningAnimations.isEmpty() && !mPressed) { mVisible = false; mDrawingHardwareGlow = false; invalidateSelf(); } } }; private static final class TraceAnimatorListener extends AnimatorListenerAdapter { private final String mName; TraceAnimatorListener(String name) { mName = name; } @Override public void onAnimationStart(Animator animation) { Trace.beginSection("KeyButtonRipple.start." + mName); Trace.endSection(); } @Override public void onAnimationCancel(Animator animation) { Trace.beginSection("KeyButtonRipple.cancel." + mName); Trace.endSection(); } @Override public void onAnimationEnd(Animator animation) { Trace.beginSection("KeyButtonRipple.end." + mName); Trace.endSection(); } } /** * Interpolator with a smooth log deceleration */ private static final class LogInterpolator implements Interpolator { @Override public float getInterpolation(float input) { return 1 - (float) Math.pow(400, -input * 1.4); } } }