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