1 /* 2 * Copyright (C) 2017 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.launcher3.folder; 18 19 import static com.android.app.animation.Interpolators.ACCELERATE_DECELERATE; 20 import static com.android.app.animation.Interpolators.EMPHASIZED_DECELERATE; 21 import static com.android.launcher3.folder.ClippedFolderIconLayoutRule.ICON_OVERLAP_FACTOR; 22 import static com.android.launcher3.graphics.IconShape.getShape; 23 import static com.android.launcher3.icons.GraphicsUtils.setColorAlphaBound; 24 25 import android.animation.Animator; 26 import android.animation.AnimatorListenerAdapter; 27 import android.animation.ObjectAnimator; 28 import android.animation.ValueAnimator; 29 import android.content.Context; 30 import android.content.res.TypedArray; 31 import android.graphics.Canvas; 32 import android.graphics.Color; 33 import android.graphics.Matrix; 34 import android.graphics.Paint; 35 import android.graphics.Path; 36 import android.graphics.PorterDuff; 37 import android.graphics.PorterDuffXfermode; 38 import android.graphics.RadialGradient; 39 import android.graphics.Rect; 40 import android.graphics.Region; 41 import android.graphics.Shader; 42 import android.util.Property; 43 import android.view.View; 44 import android.view.animation.Interpolator; 45 46 import androidx.annotation.VisibleForTesting; 47 48 import com.android.launcher3.CellLayout; 49 import com.android.launcher3.DeviceProfile; 50 import com.android.launcher3.R; 51 import com.android.launcher3.util.Themes; 52 import com.android.launcher3.views.ActivityContext; 53 54 /** 55 * This object represents a FolderIcon preview background. It stores drawing / measurement 56 * information, handles drawing, and animation (accept state <--> rest state). 57 */ 58 public class PreviewBackground extends CellLayout.DelegatedCellDrawing { 59 60 private static final boolean DRAW_SHADOW = false; 61 private static final boolean DRAW_STROKE = false; 62 63 @VisibleForTesting protected static final int CONSUMPTION_ANIMATION_DURATION = 100; 64 65 @VisibleForTesting protected static final float HOVER_SCALE = 1.1f; 66 @VisibleForTesting protected static final int HOVER_ANIMATION_DURATION = 300; 67 68 private final PorterDuffXfermode mShadowPorterDuffXfermode 69 = new PorterDuffXfermode(PorterDuff.Mode.DST_OUT); 70 private RadialGradient mShadowShader = null; 71 72 private final Matrix mShaderMatrix = new Matrix(); 73 private final Path mPath = new Path(); 74 75 private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 76 77 float mScale = 1f; 78 private int mBgColor; 79 private int mStrokeColor; 80 private int mDotColor; 81 private float mStrokeWidth; 82 private int mStrokeAlpha = MAX_BG_OPACITY; 83 private int mShadowAlpha = 255; 84 private View mInvalidateDelegate; 85 86 int previewSize; 87 int basePreviewOffsetX; 88 int basePreviewOffsetY; 89 90 private CellLayout mDrawingDelegate; 91 92 // When the PreviewBackground is drawn under an icon (for creating a folder) the border 93 // should not occlude the icon 94 public boolean isClipping = true; 95 96 // Drawing / animation configurations 97 @VisibleForTesting protected static final float ACCEPT_SCALE_FACTOR = 1.20f; 98 99 // Expressed on a scale from 0 to 255. 100 private static final int BG_OPACITY = 255; 101 private static final int MAX_BG_OPACITY = 255; 102 private static final int SHADOW_OPACITY = 40; 103 104 @VisibleForTesting protected ValueAnimator mScaleAnimator; 105 private ObjectAnimator mStrokeAlphaAnimator; 106 private ObjectAnimator mShadowAnimator; 107 108 @VisibleForTesting protected boolean mIsAccepting; 109 @VisibleForTesting protected boolean mIsHovered; 110 @VisibleForTesting protected boolean mIsHoveredOrAnimating; 111 112 private static final Property<PreviewBackground, Integer> STROKE_ALPHA = 113 new Property<PreviewBackground, Integer>(Integer.class, "strokeAlpha") { 114 @Override 115 public Integer get(PreviewBackground previewBackground) { 116 return previewBackground.mStrokeAlpha; 117 } 118 119 @Override 120 public void set(PreviewBackground previewBackground, Integer alpha) { 121 previewBackground.mStrokeAlpha = alpha; 122 previewBackground.invalidate(); 123 } 124 }; 125 126 private static final Property<PreviewBackground, Integer> SHADOW_ALPHA = 127 new Property<PreviewBackground, Integer>(Integer.class, "shadowAlpha") { 128 @Override 129 public Integer get(PreviewBackground previewBackground) { 130 return previewBackground.mShadowAlpha; 131 } 132 133 @Override 134 public void set(PreviewBackground previewBackground, Integer alpha) { 135 previewBackground.mShadowAlpha = alpha; 136 previewBackground.invalidate(); 137 } 138 }; 139 140 /** 141 * Draws folder background under cell layout 142 */ 143 @Override drawUnderItem(Canvas canvas)144 public void drawUnderItem(Canvas canvas) { 145 drawBackground(canvas); 146 if (!isClipping) { 147 drawBackgroundStroke(canvas); 148 } 149 } 150 151 /** 152 * Draws folder background on cell layout 153 */ 154 @Override drawOverItem(Canvas canvas)155 public void drawOverItem(Canvas canvas) { 156 if (isClipping) { 157 drawBackgroundStroke(canvas); 158 } 159 } 160 setup(Context context, ActivityContext activity, View invalidateDelegate, int availableSpaceX, int topPadding)161 public void setup(Context context, ActivityContext activity, View invalidateDelegate, 162 int availableSpaceX, int topPadding) { 163 mInvalidateDelegate = invalidateDelegate; 164 165 TypedArray ta = context.getTheme().obtainStyledAttributes(R.styleable.FolderIconPreview); 166 mDotColor = Themes.getAttrColor(context, R.attr.notificationDotColor); 167 mStrokeColor = ta.getColor(R.styleable.FolderIconPreview_folderIconBorderColor, 0); 168 mBgColor = ta.getColor(R.styleable.FolderIconPreview_folderPreviewColor, 0); 169 ta.recycle(); 170 171 DeviceProfile grid = activity.getDeviceProfile(); 172 previewSize = grid.folderIconSizePx; 173 174 basePreviewOffsetX = (availableSpaceX - previewSize) / 2; 175 basePreviewOffsetY = topPadding + grid.folderIconOffsetYPx; 176 177 // Stroke width is 1dp 178 mStrokeWidth = context.getResources().getDisplayMetrics().density; 179 180 if (DRAW_SHADOW) { 181 float radius = getScaledRadius(); 182 float shadowRadius = radius + mStrokeWidth; 183 int shadowColor = Color.argb(SHADOW_OPACITY, 0, 0, 0); 184 mShadowShader = new RadialGradient(0, 0, 1, 185 new int[]{shadowColor, Color.TRANSPARENT}, 186 new float[]{radius / shadowRadius, 1}, 187 Shader.TileMode.CLAMP); 188 } 189 190 invalidate(); 191 } 192 getBounds(Rect outBounds)193 void getBounds(Rect outBounds) { 194 int top = basePreviewOffsetY; 195 int left = basePreviewOffsetX; 196 int right = left + previewSize; 197 int bottom = top + previewSize; 198 outBounds.set(left, top, right, bottom); 199 } 200 getRadius()201 public int getRadius() { 202 return previewSize / 2; 203 } 204 getScaledRadius()205 int getScaledRadius() { 206 return (int) (mScale * getRadius()); 207 } 208 getOffsetX()209 int getOffsetX() { 210 return basePreviewOffsetX - (getScaledRadius() - getRadius()); 211 } 212 getOffsetY()213 int getOffsetY() { 214 return basePreviewOffsetY - (getScaledRadius() - getRadius()); 215 } 216 217 /** 218 * Returns the progress of the scale animation to accept state, where 0 means the scale is at 219 * 1f and 1 means the scale is at ACCEPT_SCALE_FACTOR. Returns 0 when scaled due to hover. 220 */ getAcceptScaleProgress()221 float getAcceptScaleProgress() { 222 return mIsHoveredOrAnimating ? 0 : (mScale - 1f) / (ACCEPT_SCALE_FACTOR - 1f); 223 } 224 invalidate()225 void invalidate() { 226 if (mInvalidateDelegate != null) { 227 mInvalidateDelegate.invalidate(); 228 } 229 230 if (mDrawingDelegate != null) { 231 mDrawingDelegate.invalidate(); 232 } 233 } 234 setInvalidateDelegate(View invalidateDelegate)235 void setInvalidateDelegate(View invalidateDelegate) { 236 mInvalidateDelegate = invalidateDelegate; 237 invalidate(); 238 } 239 getBgColor()240 public int getBgColor() { 241 return mBgColor; 242 } 243 getDotColor()244 public int getDotColor() { 245 return mDotColor; 246 } 247 drawBackground(Canvas canvas)248 public void drawBackground(Canvas canvas) { 249 mPaint.setStyle(Paint.Style.FILL); 250 mPaint.setColor(getBgColor()); 251 252 getShape().drawShape(canvas, getOffsetX(), getOffsetY(), getScaledRadius(), mPaint); 253 drawShadow(canvas); 254 } 255 drawShadow(Canvas canvas)256 public void drawShadow(Canvas canvas) { 257 if (!DRAW_SHADOW) { 258 return; 259 } 260 if (mShadowShader == null) { 261 return; 262 } 263 264 float radius = getScaledRadius(); 265 float shadowRadius = radius + mStrokeWidth; 266 mPaint.setStyle(Paint.Style.FILL); 267 mPaint.setColor(Color.BLACK); 268 int offsetX = getOffsetX(); 269 int offsetY = getOffsetY(); 270 final int saveCount; 271 if (canvas.isHardwareAccelerated()) { 272 saveCount = canvas.saveLayer(offsetX - mStrokeWidth, offsetY, 273 offsetX + radius + shadowRadius, offsetY + shadowRadius + shadowRadius, null); 274 275 } else { 276 saveCount = canvas.save(); 277 canvas.clipPath(getClipPath(), Region.Op.DIFFERENCE); 278 } 279 280 mShaderMatrix.setScale(shadowRadius, shadowRadius); 281 mShaderMatrix.postTranslate(radius + offsetX, shadowRadius + offsetY); 282 mShadowShader.setLocalMatrix(mShaderMatrix); 283 mPaint.setAlpha(mShadowAlpha); 284 mPaint.setShader(mShadowShader); 285 canvas.drawPaint(mPaint); 286 mPaint.setAlpha(255); 287 mPaint.setShader(null); 288 if (canvas.isHardwareAccelerated()) { 289 mPaint.setXfermode(mShadowPorterDuffXfermode); 290 getShape().drawShape(canvas, offsetX, offsetY, radius, mPaint); 291 mPaint.setXfermode(null); 292 } 293 294 canvas.restoreToCount(saveCount); 295 } 296 fadeInBackgroundShadow()297 public void fadeInBackgroundShadow() { 298 if (!DRAW_SHADOW) { 299 return; 300 } 301 if (mShadowAnimator != null) { 302 mShadowAnimator.cancel(); 303 } 304 mShadowAnimator = ObjectAnimator 305 .ofInt(this, SHADOW_ALPHA, 0, 255) 306 .setDuration(100); 307 mShadowAnimator.addListener(new AnimatorListenerAdapter() { 308 @Override 309 public void onAnimationEnd(Animator animation) { 310 mShadowAnimator = null; 311 } 312 }); 313 mShadowAnimator.start(); 314 } 315 animateBackgroundStroke()316 public void animateBackgroundStroke() { 317 if (!DRAW_STROKE) { 318 return; 319 } 320 321 if (mStrokeAlphaAnimator != null) { 322 mStrokeAlphaAnimator.cancel(); 323 } 324 mStrokeAlphaAnimator = ObjectAnimator 325 .ofInt(this, STROKE_ALPHA, MAX_BG_OPACITY / 2, MAX_BG_OPACITY) 326 .setDuration(100); 327 mStrokeAlphaAnimator.addListener(new AnimatorListenerAdapter() { 328 @Override 329 public void onAnimationEnd(Animator animation) { 330 mStrokeAlphaAnimator = null; 331 } 332 }); 333 mStrokeAlphaAnimator.start(); 334 } 335 drawBackgroundStroke(Canvas canvas)336 public void drawBackgroundStroke(Canvas canvas) { 337 if (!DRAW_STROKE) { 338 return; 339 } 340 mPaint.setColor(setColorAlphaBound(mStrokeColor, mStrokeAlpha)); 341 mPaint.setStyle(Paint.Style.STROKE); 342 mPaint.setStrokeWidth(mStrokeWidth); 343 344 float inset = 1f; 345 getShape().drawShape(canvas, 346 getOffsetX() + inset, getOffsetY() + inset, getScaledRadius() - inset, mPaint); 347 } 348 349 /** 350 * Draws the leave-behind circle on the given canvas and in the given color. 351 */ drawLeaveBehind(Canvas canvas, int color)352 public void drawLeaveBehind(Canvas canvas, int color) { 353 float originalScale = mScale; 354 mScale = 0.5f; 355 356 mPaint.setStyle(Paint.Style.FILL); 357 mPaint.setColor(color); 358 getShape().drawShape(canvas, getOffsetX(), getOffsetY(), getScaledRadius(), mPaint); 359 360 mScale = originalScale; 361 } 362 getClipPath()363 public Path getClipPath() { 364 mPath.reset(); 365 float radius = getScaledRadius() * ICON_OVERLAP_FACTOR; 366 // Find the difference in radius so that the clip path remains centered. 367 float radiusDifference = radius - getRadius(); 368 float offsetX = basePreviewOffsetX - radiusDifference; 369 float offsetY = basePreviewOffsetY - radiusDifference; 370 getShape().addToPath(mPath, offsetX, offsetY, radius); 371 return mPath; 372 } 373 delegateDrawing(CellLayout delegate, int cellX, int cellY)374 private void delegateDrawing(CellLayout delegate, int cellX, int cellY) { 375 if (mDrawingDelegate != delegate) { 376 delegate.addDelegatedCellDrawing(this); 377 } 378 379 mDrawingDelegate = delegate; 380 mDelegateCellX = cellX; 381 mDelegateCellY = cellY; 382 383 invalidate(); 384 } 385 clearDrawingDelegate()386 private void clearDrawingDelegate() { 387 if (mDrawingDelegate != null) { 388 mDrawingDelegate.removeDelegatedCellDrawing(this); 389 } 390 391 mDrawingDelegate = null; 392 isClipping = false; 393 invalidate(); 394 } 395 drawingDelegated()396 boolean drawingDelegated() { 397 return mDrawingDelegate != null; 398 } 399 animateScale(boolean isAccepting, boolean isHovered)400 protected void animateScale(boolean isAccepting, boolean isHovered) { 401 if (mScaleAnimator != null) { 402 mScaleAnimator.cancel(); 403 } 404 405 final float startScale = mScale; 406 final float endScale = isAccepting ? ACCEPT_SCALE_FACTOR : (isHovered ? HOVER_SCALE : 1f); 407 Interpolator interpolator = 408 isAccepting != mIsAccepting ? ACCELERATE_DECELERATE : EMPHASIZED_DECELERATE; 409 int duration = isAccepting != mIsAccepting ? CONSUMPTION_ANIMATION_DURATION 410 : HOVER_ANIMATION_DURATION; 411 mIsAccepting = isAccepting; 412 mIsHovered = isHovered; 413 if (startScale == endScale) { 414 if (!mIsAccepting) { 415 clearDrawingDelegate(); 416 } 417 mIsHoveredOrAnimating = mIsHovered; 418 return; 419 } 420 421 422 mScaleAnimator = ValueAnimator.ofFloat(0f, 1.0f); 423 mScaleAnimator.addUpdateListener(animation -> { 424 float prog = animation.getAnimatedFraction(); 425 mScale = prog * endScale + (1 - prog) * startScale; 426 invalidate(); 427 }); 428 mScaleAnimator.addListener(new AnimatorListenerAdapter() { 429 @Override 430 public void onAnimationStart(Animator animation) { 431 if (mIsHovered) { 432 mIsHoveredOrAnimating = true; 433 } 434 } 435 436 @Override 437 public void onAnimationEnd(Animator animation) { 438 if (!mIsAccepting) { 439 clearDrawingDelegate(); 440 } 441 mIsHoveredOrAnimating = mIsHovered; 442 mScaleAnimator = null; 443 } 444 }); 445 mScaleAnimator.setInterpolator(interpolator); 446 mScaleAnimator.setDuration(duration); 447 mScaleAnimator.start(); 448 } 449 animateToAccept(CellLayout cl, int cellX, int cellY)450 public void animateToAccept(CellLayout cl, int cellX, int cellY) { 451 delegateDrawing(cl, cellX, cellY); 452 animateScale(/* isAccepting= */ true, mIsHovered); 453 } 454 animateToRest()455 public void animateToRest() { 456 animateScale(/* isAccepting= */ false, mIsHovered); 457 } 458 getStrokeWidth()459 public float getStrokeWidth() { 460 return mStrokeWidth; 461 } 462 setHovered(boolean hovered)463 protected void setHovered(boolean hovered) { 464 animateScale(mIsAccepting, /* isHovered= */ hovered); 465 } 466 } 467