1 /* 2 * Copyright (C) 2010 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.widget; 18 19 import android.annotation.ColorInt; 20 import android.annotation.Nullable; 21 import android.compat.annotation.UnsupportedAppUsage; 22 import android.content.Context; 23 import android.content.res.TypedArray; 24 import android.graphics.BlendMode; 25 import android.graphics.Canvas; 26 import android.graphics.Paint; 27 import android.graphics.Rect; 28 import android.os.Build; 29 import android.view.animation.AnimationUtils; 30 import android.view.animation.DecelerateInterpolator; 31 import android.view.animation.Interpolator; 32 33 /** 34 * This class performs the graphical effect used at the edges of scrollable widgets 35 * when the user scrolls beyond the content bounds in 2D space. 36 * 37 * <p>EdgeEffect is stateful. Custom widgets using EdgeEffect should create an 38 * instance for each edge that should show the effect, feed it input data using 39 * the methods {@link #onAbsorb(int)}, {@link #onPull(float)}, and {@link #onRelease()}, 40 * and draw the effect using {@link #draw(Canvas)} in the widget's overridden 41 * {@link android.view.View#draw(Canvas)} method. If {@link #isFinished()} returns 42 * false after drawing, the edge effect's animation is not yet complete and the widget 43 * should schedule another drawing pass to continue the animation.</p> 44 * 45 * <p>When drawing, widgets should draw their main content and child views first, 46 * usually by invoking <code>super.draw(canvas)</code> from an overridden <code>draw</code> 47 * method. (This will invoke onDraw and dispatch drawing to child views as needed.) 48 * The edge effect may then be drawn on top of the view's content using the 49 * {@link #draw(Canvas)} method.</p> 50 */ 51 public class EdgeEffect { 52 53 /** 54 * The default blend mode used by {@link EdgeEffect}. 55 */ 56 public static final BlendMode DEFAULT_BLEND_MODE = BlendMode.SRC_ATOP; 57 58 @SuppressWarnings("UnusedDeclaration") 59 private static final String TAG = "EdgeEffect"; 60 61 // Time it will take the effect to fully recede in ms 62 private static final int RECEDE_TIME = 600; 63 64 // Time it will take before a pulled glow begins receding in ms 65 private static final int PULL_TIME = 167; 66 67 // Time it will take in ms for a pulled glow to decay to partial strength before release 68 private static final int PULL_DECAY_TIME = 2000; 69 70 private static final float MAX_ALPHA = 0.15f; 71 private static final float GLOW_ALPHA_START = .09f; 72 73 private static final float MAX_GLOW_SCALE = 2.f; 74 75 private static final float PULL_GLOW_BEGIN = 0.f; 76 77 // Minimum velocity that will be absorbed 78 private static final int MIN_VELOCITY = 100; 79 // Maximum velocity, clamps at this value 80 private static final int MAX_VELOCITY = 10000; 81 82 private static final float EPSILON = 0.001f; 83 84 private static final double ANGLE = Math.PI / 6; 85 private static final float SIN = (float) Math.sin(ANGLE); 86 private static final float COS = (float) Math.cos(ANGLE); 87 private static final float RADIUS_FACTOR = 0.6f; 88 89 private float mGlowAlpha; 90 @UnsupportedAppUsage 91 private float mGlowScaleY; 92 93 private float mGlowAlphaStart; 94 private float mGlowAlphaFinish; 95 private float mGlowScaleYStart; 96 private float mGlowScaleYFinish; 97 98 private long mStartTime; 99 private float mDuration; 100 101 private final Interpolator mInterpolator; 102 103 private static final int STATE_IDLE = 0; 104 private static final int STATE_PULL = 1; 105 private static final int STATE_ABSORB = 2; 106 private static final int STATE_RECEDE = 3; 107 private static final int STATE_PULL_DECAY = 4; 108 109 private static final float PULL_DISTANCE_ALPHA_GLOW_FACTOR = 0.8f; 110 111 private static final int VELOCITY_GLOW_FACTOR = 6; 112 113 private int mState = STATE_IDLE; 114 115 private float mPullDistance; 116 117 private final Rect mBounds = new Rect(); 118 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 123769450) 119 private final Paint mPaint = new Paint(); 120 private float mRadius; 121 private float mBaseGlowScale; 122 private float mDisplacement = 0.5f; 123 private float mTargetDisplacement = 0.5f; 124 125 /** 126 * Construct a new EdgeEffect with a theme appropriate for the provided context. 127 * @param context Context used to provide theming and resource information for the EdgeEffect 128 */ EdgeEffect(Context context)129 public EdgeEffect(Context context) { 130 mPaint.setAntiAlias(true); 131 final TypedArray a = context.obtainStyledAttributes( 132 com.android.internal.R.styleable.EdgeEffect); 133 final int themeColor = a.getColor( 134 com.android.internal.R.styleable.EdgeEffect_colorEdgeEffect, 0xff666666); 135 a.recycle(); 136 mPaint.setColor((themeColor & 0xffffff) | 0x33000000); 137 mPaint.setStyle(Paint.Style.FILL); 138 mPaint.setBlendMode(DEFAULT_BLEND_MODE); 139 mInterpolator = new DecelerateInterpolator(); 140 } 141 142 /** 143 * Set the size of this edge effect in pixels. 144 * 145 * @param width Effect width in pixels 146 * @param height Effect height in pixels 147 */ setSize(int width, int height)148 public void setSize(int width, int height) { 149 final float r = width * RADIUS_FACTOR / SIN; 150 final float y = COS * r; 151 final float h = r - y; 152 final float or = height * RADIUS_FACTOR / SIN; 153 final float oy = COS * or; 154 final float oh = or - oy; 155 156 mRadius = r; 157 mBaseGlowScale = h > 0 ? Math.min(oh / h, 1.f) : 1.f; 158 159 mBounds.set(mBounds.left, mBounds.top, width, (int) Math.min(height, h)); 160 } 161 162 /** 163 * Reports if this EdgeEffect's animation is finished. If this method returns false 164 * after a call to {@link #draw(Canvas)} the host widget should schedule another 165 * drawing pass to continue the animation. 166 * 167 * @return true if animation is finished, false if drawing should continue on the next frame. 168 */ isFinished()169 public boolean isFinished() { 170 return mState == STATE_IDLE; 171 } 172 173 /** 174 * Immediately finish the current animation. 175 * After this call {@link #isFinished()} will return true. 176 */ finish()177 public void finish() { 178 mState = STATE_IDLE; 179 } 180 181 /** 182 * A view should call this when content is pulled away from an edge by the user. 183 * This will update the state of the current visual effect and its associated animation. 184 * The host view should always {@link android.view.View#invalidate()} after this 185 * and draw the results accordingly. 186 * 187 * <p>Views using EdgeEffect should favor {@link #onPull(float, float)} when the displacement 188 * of the pull point is known.</p> 189 * 190 * @param deltaDistance Change in distance since the last call. Values may be 0 (no change) to 191 * 1.f (full length of the view) or negative values to express change 192 * back toward the edge reached to initiate the effect. 193 */ onPull(float deltaDistance)194 public void onPull(float deltaDistance) { 195 onPull(deltaDistance, 0.5f); 196 } 197 198 /** 199 * A view should call this when content is pulled away from an edge by the user. 200 * This will update the state of the current visual effect and its associated animation. 201 * The host view should always {@link android.view.View#invalidate()} after this 202 * and draw the results accordingly. 203 * 204 * @param deltaDistance Change in distance since the last call. Values may be 0 (no change) to 205 * 1.f (full length of the view) or negative values to express change 206 * back toward the edge reached to initiate the effect. 207 * @param displacement The displacement from the starting side of the effect of the point 208 * initiating the pull. In the case of touch this is the finger position. 209 * Values may be from 0-1. 210 */ onPull(float deltaDistance, float displacement)211 public void onPull(float deltaDistance, float displacement) { 212 final long now = AnimationUtils.currentAnimationTimeMillis(); 213 mTargetDisplacement = displacement; 214 if (mState == STATE_PULL_DECAY && now - mStartTime < mDuration) { 215 return; 216 } 217 if (mState != STATE_PULL) { 218 mGlowScaleY = Math.max(PULL_GLOW_BEGIN, mGlowScaleY); 219 } 220 mState = STATE_PULL; 221 222 mStartTime = now; 223 mDuration = PULL_TIME; 224 225 mPullDistance += deltaDistance; 226 227 final float absdd = Math.abs(deltaDistance); 228 mGlowAlpha = mGlowAlphaStart = Math.min(MAX_ALPHA, 229 mGlowAlpha + (absdd * PULL_DISTANCE_ALPHA_GLOW_FACTOR)); 230 231 if (mPullDistance == 0) { 232 mGlowScaleY = mGlowScaleYStart = 0; 233 } else { 234 final float scale = (float) (Math.max(0, 1 - 1 / 235 Math.sqrt(Math.abs(mPullDistance) * mBounds.height()) - 0.3d) / 0.7d); 236 237 mGlowScaleY = mGlowScaleYStart = scale; 238 } 239 240 mGlowAlphaFinish = mGlowAlpha; 241 mGlowScaleYFinish = mGlowScaleY; 242 } 243 244 /** 245 * Call when the object is released after being pulled. 246 * This will begin the "decay" phase of the effect. After calling this method 247 * the host view should {@link android.view.View#invalidate()} and thereby 248 * draw the results accordingly. 249 */ onRelease()250 public void onRelease() { 251 mPullDistance = 0; 252 253 if (mState != STATE_PULL && mState != STATE_PULL_DECAY) { 254 return; 255 } 256 257 mState = STATE_RECEDE; 258 mGlowAlphaStart = mGlowAlpha; 259 mGlowScaleYStart = mGlowScaleY; 260 261 mGlowAlphaFinish = 0.f; 262 mGlowScaleYFinish = 0.f; 263 264 mStartTime = AnimationUtils.currentAnimationTimeMillis(); 265 mDuration = RECEDE_TIME; 266 } 267 268 /** 269 * Call when the effect absorbs an impact at the given velocity. 270 * Used when a fling reaches the scroll boundary. 271 * 272 * <p>When using a {@link android.widget.Scroller} or {@link android.widget.OverScroller}, 273 * the method <code>getCurrVelocity</code> will provide a reasonable approximation 274 * to use here.</p> 275 * 276 * @param velocity Velocity at impact in pixels per second. 277 */ onAbsorb(int velocity)278 public void onAbsorb(int velocity) { 279 mState = STATE_ABSORB; 280 velocity = Math.min(Math.max(MIN_VELOCITY, Math.abs(velocity)), MAX_VELOCITY); 281 282 mStartTime = AnimationUtils.currentAnimationTimeMillis(); 283 mDuration = 0.15f + (velocity * 0.02f); 284 285 // The glow depends more on the velocity, and therefore starts out 286 // nearly invisible. 287 mGlowAlphaStart = GLOW_ALPHA_START; 288 mGlowScaleYStart = Math.max(mGlowScaleY, 0.f); 289 290 291 // Growth for the size of the glow should be quadratic to properly 292 // respond 293 // to a user's scrolling speed. The faster the scrolling speed, the more 294 // intense the effect should be for both the size and the saturation. 295 mGlowScaleYFinish = Math.min(0.025f + (velocity * (velocity / 100) * 0.00015f) / 2, 1.f); 296 // Alpha should change for the glow as well as size. 297 mGlowAlphaFinish = Math.max( 298 mGlowAlphaStart, Math.min(velocity * VELOCITY_GLOW_FACTOR * .00001f, MAX_ALPHA)); 299 mTargetDisplacement = 0.5f; 300 } 301 302 /** 303 * Set the color of this edge effect in argb. 304 * 305 * @param color Color in argb 306 */ setColor(@olorInt int color)307 public void setColor(@ColorInt int color) { 308 mPaint.setColor(color); 309 } 310 311 /** 312 * Set or clear the blend mode. A blend mode defines how source pixels 313 * (generated by a drawing command) are composited with the destination pixels 314 * (content of the render target). 315 * <p /> 316 * Pass null to clear any previous blend mode. 317 * <p /> 318 * 319 * @see BlendMode 320 * 321 * @param blendmode May be null. The blend mode to be installed in the paint 322 */ setBlendMode(@ullable BlendMode blendmode)323 public void setBlendMode(@Nullable BlendMode blendmode) { 324 mPaint.setBlendMode(blendmode); 325 } 326 327 /** 328 * Return the color of this edge effect in argb. 329 * @return The color of this edge effect in argb 330 */ 331 @ColorInt getColor()332 public int getColor() { 333 return mPaint.getColor(); 334 } 335 336 337 /** 338 * Returns the blend mode. A blend mode defines how source pixels 339 * (generated by a drawing command) are composited with the destination pixels 340 * (content of the render target). 341 * <p /> 342 * 343 * @return BlendMode 344 */ 345 @Nullable getBlendMode()346 public BlendMode getBlendMode() { 347 return mPaint.getBlendMode(); 348 } 349 350 /** 351 * Draw into the provided canvas. Assumes that the canvas has been rotated 352 * accordingly and the size has been set. The effect will be drawn the full 353 * width of X=0 to X=width, beginning from Y=0 and extending to some factor < 354 * 1.f of height. 355 * 356 * @param canvas Canvas to draw into 357 * @return true if drawing should continue beyond this frame to continue the 358 * animation 359 */ draw(Canvas canvas)360 public boolean draw(Canvas canvas) { 361 update(); 362 363 final int count = canvas.save(); 364 365 final float centerX = mBounds.centerX(); 366 final float centerY = mBounds.height() - mRadius; 367 368 canvas.scale(1.f, Math.min(mGlowScaleY, 1.f) * mBaseGlowScale, centerX, 0); 369 370 final float displacement = Math.max(0, Math.min(mDisplacement, 1.f)) - 0.5f; 371 float translateX = mBounds.width() * displacement / 2; 372 373 canvas.clipRect(mBounds); 374 canvas.translate(translateX, 0); 375 mPaint.setAlpha((int) (0xff * mGlowAlpha)); 376 canvas.drawCircle(centerX, centerY, mRadius, mPaint); 377 canvas.restoreToCount(count); 378 379 boolean oneLastFrame = false; 380 if (mState == STATE_RECEDE && mGlowScaleY == 0) { 381 mState = STATE_IDLE; 382 oneLastFrame = true; 383 } 384 385 return mState != STATE_IDLE || oneLastFrame; 386 } 387 388 /** 389 * Return the maximum height that the edge effect will be drawn at given the original 390 * {@link #setSize(int, int) input size}. 391 * @return The maximum height of the edge effect 392 */ getMaxHeight()393 public int getMaxHeight() { 394 return (int) (mBounds.height() * MAX_GLOW_SCALE + 0.5f); 395 } 396 update()397 private void update() { 398 final long time = AnimationUtils.currentAnimationTimeMillis(); 399 final float t = Math.min((time - mStartTime) / mDuration, 1.f); 400 401 final float interp = mInterpolator.getInterpolation(t); 402 403 mGlowAlpha = mGlowAlphaStart + (mGlowAlphaFinish - mGlowAlphaStart) * interp; 404 mGlowScaleY = mGlowScaleYStart + (mGlowScaleYFinish - mGlowScaleYStart) * interp; 405 mDisplacement = (mDisplacement + mTargetDisplacement) / 2; 406 407 if (t >= 1.f - EPSILON) { 408 switch (mState) { 409 case STATE_ABSORB: 410 mState = STATE_RECEDE; 411 mStartTime = AnimationUtils.currentAnimationTimeMillis(); 412 mDuration = RECEDE_TIME; 413 414 mGlowAlphaStart = mGlowAlpha; 415 mGlowScaleYStart = mGlowScaleY; 416 417 // After absorb, the glow should fade to nothing. 418 mGlowAlphaFinish = 0.f; 419 mGlowScaleYFinish = 0.f; 420 break; 421 case STATE_PULL: 422 mState = STATE_PULL_DECAY; 423 mStartTime = AnimationUtils.currentAnimationTimeMillis(); 424 mDuration = PULL_DECAY_TIME; 425 426 mGlowAlphaStart = mGlowAlpha; 427 mGlowScaleYStart = mGlowScaleY; 428 429 // After pull, the glow should fade to nothing. 430 mGlowAlphaFinish = 0.f; 431 mGlowScaleYFinish = 0.f; 432 break; 433 case STATE_PULL_DECAY: 434 mState = STATE_RECEDE; 435 break; 436 case STATE_RECEDE: 437 mState = STATE_IDLE; 438 break; 439 } 440 } 441 } 442 } 443