/* * Copyright (C) 2013 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 com.hippo.effect.ripple; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; import android.annotation.TargetApi; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Paint.Style; import android.graphics.Rect; import android.os.Build; import android.view.animation.Interpolator; import android.view.animation.LinearInterpolator; import com.hippo.util.MathUtils; /** * Draws a Material ripple. */ class Ripple { @SuppressWarnings("unused") private static final String TAG = Ripple.class.getSimpleName(); private static final Interpolator LINEAR_INTERPOLATOR = new LinearInterpolator(); private static final Interpolator DECEL_INTERPOLATOR = new LogInterpolator(); private static final float GLOBAL_SPEED = 1.0f; private static final float WAVE_TOUCH_DOWN_ACCELERATION = 1024.0f * GLOBAL_SPEED; private static final float WAVE_TOUCH_UP_ACCELERATION = 3400.0f * GLOBAL_SPEED; private static final float WAVE_OPACITY_DECAY_VELOCITY = 3.0f / GLOBAL_SPEED; private static final long RIPPLE_ENTER_DELAY = 80; private final RippleOwner mOwner; /** Bounds used for computing max radius. */ private final Rect mBounds; /** Full-opacity color for drawing this ripple. */ private int mColorOpaque; /** Maximum ripple radius. */ private float mOuterRadius; /** Screen density used to adjust pixel-based velocities. */ private float mDensity; private float mStartingX; private float mStartingY; private float mClampedStartingX; private float mClampedStartingY; // Software animators. private ObjectAnimator mAnimRadius; private ObjectAnimator mAnimOpacity; private ObjectAnimator mAnimX; private ObjectAnimator mAnimY; // Software rendering properties. private float mOpacity = 1; private float mOuterX; private float mOuterY; // Values used to tween between the start and end positions. private float mTweenRadius = 0; private float mTweenX = 0; private float mTweenY = 0; /** Whether we have an explicit maximum radius. */ private boolean mHasMaxRadius; /** Whether we were canceled externally and should avoid self-removal. */ private boolean mCanceled; /** * Creates a new ripple. */ public Ripple(RippleOwner owner, Rect bounds, float startingX, float startingY) { mOwner = owner; mBounds = bounds; mStartingX = startingX; mStartingY = startingY; } public void setup(int maxRadius, int color, float density) { mColorOpaque = color | 0xFF000000; mOpacity = Color.alpha(color) / 2 / 255.0f; if (maxRadius != RippleOwner.RADIUS_AUTO) { mHasMaxRadius = true; mOuterRadius = maxRadius; } else { final float halfWidth = mBounds.width() / 2.0f; final float halfHeight = mBounds.height() / 2.0f; mOuterRadius = (float) Math.sqrt(halfWidth * halfWidth + halfHeight * halfHeight); } mOuterX = 0; mOuterY = 0; mDensity = density; clampStartingPosition(); } private void clampStartingPosition() { final float cX = mBounds.exactCenterX(); final float cY = mBounds.exactCenterY(); final float dX = mStartingX - cX; final float dY = mStartingY - cY; final float r = mOuterRadius; if (dX * dX + dY * dY > r * r) { // Point is outside the circle, clamp to the circumference. final double angle = Math.atan2(dY, dX); mClampedStartingX = cX + (float) (Math.cos(angle) * r); mClampedStartingY = cY + (float) (Math.sin(angle) * r); } else { mClampedStartingX = mStartingX; mClampedStartingY = mStartingY; } } public void onHotspotBoundsChanged() { if (!mHasMaxRadius) { final float halfWidth = mBounds.width() / 2.0f; final float halfHeight = mBounds.height() / 2.0f; mOuterRadius = (float) Math.sqrt(halfWidth * halfWidth + halfHeight * halfHeight); clampStartingPosition(); } } public void setOpacity(float a) { mOpacity = a; invalidateSelf(); } public float getOpacity() { return mOpacity; } public void setRadiusGravity(float r) { mTweenRadius = r; invalidateSelf(); } public float getRadiusGravity() { return mTweenRadius; } public void setXGravity(float x) { mTweenX = x; invalidateSelf(); } public float getXGravity() { return mTweenX; } public void setYGravity(float y) { mTweenY = y; invalidateSelf(); } public float getYGravity() { return mTweenY; } /** * Draws the ripple centered at (0,0) using the specified paint. */ public boolean draw(Canvas c, Paint p) { return drawSoftware(c, p); } private boolean drawSoftware(Canvas c, Paint p) { boolean hasContent = false; p.setColor(mColorOpaque); final int alpha = MathUtils.clamp((int) (255 * mOpacity + 0.5f), 0, 255); final float radius = MathUtils.lerp(0, mOuterRadius, mTweenRadius); if (alpha > 0 && radius > 0) { final float x = MathUtils.lerp( mClampedStartingX - mBounds.exactCenterX(), mOuterX, mTweenX); final float y = MathUtils.lerp( mClampedStartingY - mBounds.exactCenterY(), mOuterY, mTweenY); p.setAlpha(alpha); p.setStyle(Style.FILL); c.drawCircle(x, y, radius, p); hasContent = true; } return hasContent; } /** * Returns the maximum bounds of the ripple relative to the ripple center. */ public void getBounds(Rect bounds) { final int outerX = (int) mOuterX; final int outerY = (int) mOuterY; final int r = (int) mOuterRadius + 1; bounds.set(outerX - r, outerY - r, outerX + r, outerY + r); } /** * Specifies the starting position relative to the drawable bounds. No-op if * the ripple has already entered. */ public void move(float x, float y) { mStartingX = x; mStartingY = y; clampStartingPosition(); } /** * Starts the enter animation. */ @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) public void enter() { cancel(); final int radiusDuration = (int) (1000 * Math.sqrt(mOuterRadius / WAVE_TOUCH_DOWN_ACCELERATION * mDensity) + 0.5); final ObjectAnimator radius = ObjectAnimator.ofFloat(this, "radiusGravity", 1); radius.setDuration(radiusDuration); radius.setInterpolator(LINEAR_INTERPOLATOR); radius.setStartDelay(RIPPLE_ENTER_DELAY); final ObjectAnimator cX = ObjectAnimator.ofFloat(this, "xGravity", 1); cX.setDuration(radiusDuration); cX.setInterpolator(LINEAR_INTERPOLATOR); cX.setStartDelay(RIPPLE_ENTER_DELAY); final ObjectAnimator cY = ObjectAnimator.ofFloat(this, "yGravity", 1); cY.setDuration(radiusDuration); cY.setInterpolator(LINEAR_INTERPOLATOR); cY.setStartDelay(RIPPLE_ENTER_DELAY); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { radius.setAutoCancel(true); cX.setAutoCancel(true); cY.setAutoCancel(true); } mAnimRadius = radius; mAnimX = cX; mAnimY = cY; // Enter animations always run on the UI thread, since it's unlikely // that anything interesting is happening until the user lifts their // finger. radius.start(); cX.start(); cY.start(); } /** * Starts the exit animation. */ public void exit() { cancel(); final float radius = MathUtils.lerp(0, mOuterRadius, mTweenRadius); final float remaining; if (mAnimRadius != null && mAnimRadius.isRunning()) { remaining = mOuterRadius - radius; } else { remaining = mOuterRadius; } final int radiusDuration = (int) (1000 * Math.sqrt(remaining / (WAVE_TOUCH_UP_ACCELERATION + WAVE_TOUCH_DOWN_ACCELERATION) * mDensity) + 0.5); final int opacityDuration = (int) (1000 * 1 / WAVE_OPACITY_DECAY_VELOCITY + 0.5f); exitSoftware(radiusDuration, opacityDuration); } /** * Jump all animations to their end state. The caller is responsible for * removing the ripple from the list of animating ripples. */ public void jump() { mCanceled = true; endSoftwareAnimations(); mCanceled = false; } private void endSoftwareAnimations() { if (mAnimRadius != null) { mAnimRadius.end(); mAnimRadius = null; } if (mAnimOpacity != null) { mAnimOpacity.end(); mAnimOpacity = null; } if (mAnimX != null) { mAnimX.end(); mAnimX = null; } if (mAnimY != null) { mAnimY.end(); mAnimY = null; } } @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) private void exitSoftware(int radiusDuration, int opacityDuration) { final ObjectAnimator radiusAnim = ObjectAnimator.ofFloat(this, "radiusGravity", 1); radiusAnim.setDuration(radiusDuration); radiusAnim.setInterpolator(DECEL_INTERPOLATOR); final ObjectAnimator xAnim = ObjectAnimator.ofFloat(this, "xGravity", 1); xAnim.setDuration(radiusDuration); xAnim.setInterpolator(DECEL_INTERPOLATOR); final ObjectAnimator yAnim = ObjectAnimator.ofFloat(this, "yGravity", 1); yAnim.setDuration(radiusDuration); yAnim.setInterpolator(DECEL_INTERPOLATOR); final ObjectAnimator opacityAnim = ObjectAnimator.ofFloat(this, "opacity", 0); opacityAnim.setDuration(opacityDuration); opacityAnim.setInterpolator(LINEAR_INTERPOLATOR); opacityAnim.addListener(mAnimationListener); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { radiusAnim.setAutoCancel(true); xAnim.setAutoCancel(true); yAnim.setAutoCancel(true); opacityAnim.setAutoCancel(true); } mAnimRadius = radiusAnim; mAnimOpacity = opacityAnim; mAnimX = xAnim; mAnimY = yAnim; radiusAnim.start(); opacityAnim.start(); xAnim.start(); yAnim.start(); } /** * Cancels all animations. The caller is responsible for removing * the ripple from the list of animating ripples. */ public void cancel() { mCanceled = true; cancelSoftwareAnimations(); mCanceled = false; } private void cancelSoftwareAnimations() { if (mAnimRadius != null) { mAnimRadius.cancel(); mAnimRadius = null; } if (mAnimOpacity != null) { mAnimOpacity.cancel(); mAnimOpacity = null; } if (mAnimX != null) { mAnimX.cancel(); mAnimX = null; } if (mAnimY != null) { mAnimY.cancel(); mAnimY = null; } } private void removeSelf() { // The owner will invalidate itself. if (!mCanceled) { mOwner.removeRipple(this); } } private void invalidateSelf() { mOwner.invalidateSelf(); } private final AnimatorListenerAdapter mAnimationListener = new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { removeSelf(); } }; /** * 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); } } }