1 /* 2 * Copyright (C) 2019 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 package com.android.launcher3.uioverrides; 17 18 import static com.android.app.animation.Interpolators.ACCELERATE_DECELERATE; 19 import static com.android.launcher3.icons.FastBitmapDrawable.getDisabledColorFilter; 20 import static com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR; 21 22 import android.animation.Animator; 23 import android.animation.AnimatorListenerAdapter; 24 import android.animation.AnimatorSet; 25 import android.animation.ArgbEvaluator; 26 import android.animation.Keyframe; 27 import android.animation.ObjectAnimator; 28 import android.animation.PropertyValuesHolder; 29 import android.content.Context; 30 import android.graphics.BlurMaskFilter; 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.Rect; 37 import android.graphics.drawable.Drawable; 38 import android.util.AttributeSet; 39 import android.util.FloatProperty; 40 import android.util.Log; 41 import android.util.Property; 42 import android.view.LayoutInflater; 43 import android.view.ViewGroup; 44 45 import androidx.core.graphics.ColorUtils; 46 47 import com.android.launcher3.DeviceProfile; 48 import com.android.launcher3.Flags; 49 import com.android.launcher3.Launcher; 50 import com.android.launcher3.R; 51 import com.android.launcher3.anim.AnimatedFloat; 52 import com.android.launcher3.anim.AnimatorListeners; 53 import com.android.launcher3.celllayout.CellLayoutLayoutParams; 54 import com.android.launcher3.celllayout.DelegatedCellDrawing; 55 import com.android.launcher3.graphics.ThemeManager; 56 import com.android.launcher3.icons.FastBitmapDrawable; 57 import com.android.launcher3.icons.LauncherIcons; 58 import com.android.launcher3.model.data.ItemInfoWithIcon; 59 import com.android.launcher3.model.data.WorkspaceItemInfo; 60 import com.android.launcher3.touch.ItemLongClickListener; 61 import com.android.launcher3.util.SafeCloseable; 62 import com.android.launcher3.views.ActivityContext; 63 import com.android.launcher3.views.DoubleShadowBubbleTextView; 64 65 /** 66 * A BubbleTextView with a ring around it's drawable 67 */ 68 public class PredictedAppIcon extends DoubleShadowBubbleTextView { 69 70 private static final float RING_SCALE_START_VALUE = 0.75f; 71 private static final int RING_SHADOW_COLOR = 0x99000000; 72 private static final float RING_EFFECT_RATIO = Flags.enableLauncherIconShapes() ? 0.1f : 0.095f; 73 private static final long ICON_CHANGE_ANIM_DURATION = 360; 74 private static final long ICON_CHANGE_ANIM_STAGGER = 50; 75 76 private static final Property<PredictedAppIcon, Float> RING_SCALE_PROPERTY = 77 new Property<>(Float.TYPE, "ringScale") { 78 @Override 79 public Float get(PredictedAppIcon icon) { 80 return icon.mRingScale; 81 } 82 83 @Override 84 public void set(PredictedAppIcon icon, Float value) { 85 icon.mRingScale = value; 86 icon.invalidate(); 87 } 88 }; 89 90 boolean mIsDrawingDot = false; 91 private final DeviceProfile mDeviceProfile; 92 private final Paint mIconRingPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 93 private final Path mRingPath = new Path(); 94 private final int mNormalizedIconSize; 95 private final Path mShapePath; 96 private final Matrix mTmpMatrix = new Matrix(); 97 98 private final BlurMaskFilter mShadowFilter; 99 100 private boolean mIsPinned = false; 101 private final AnimColorHolder mPlateColor = new AnimColorHolder(); 102 boolean mDrawForDrag = false; 103 104 // Used for the "slot-machine" animation when prediction changes. 105 private final Rect mSlotIconBound = new Rect(0, 0, getIconSize(), getIconSize()); 106 private Drawable mSlotMachineIcon; 107 private float mSlotMachineIconTranslationY; 108 109 // Used to animate the "ring" around predicted icons 110 private float mRingScale = 1f; 111 private boolean mForceHideRing = false; 112 private Animator mRingScaleAnim; 113 114 private int mWidth; 115 116 private static final FloatProperty<PredictedAppIcon> SLOT_MACHINE_TRANSLATION_Y = 117 new FloatProperty<PredictedAppIcon>("slotMachineTranslationY") { 118 @Override 119 public void setValue(PredictedAppIcon predictedAppIcon, float transY) { 120 predictedAppIcon.mSlotMachineIconTranslationY = transY; 121 predictedAppIcon.invalidate(); 122 } 123 124 @Override 125 public Float get(PredictedAppIcon predictedAppIcon) { 126 return predictedAppIcon.mSlotMachineIconTranslationY; 127 } 128 }; 129 PredictedAppIcon(Context context)130 public PredictedAppIcon(Context context) { 131 this(context, null, 0); 132 } 133 PredictedAppIcon(Context context, AttributeSet attrs)134 public PredictedAppIcon(Context context, AttributeSet attrs) { 135 this(context, attrs, 0); 136 } 137 PredictedAppIcon(Context context, AttributeSet attrs, int defStyle)138 public PredictedAppIcon(Context context, AttributeSet attrs, int defStyle) { 139 super(context, attrs, defStyle); 140 mDeviceProfile = ActivityContext.lookupContext(context).getDeviceProfile(); 141 mNormalizedIconSize = Math.round(getIconSize() * ICON_VISIBLE_AREA_FACTOR); 142 int shadowSize = context.getResources().getDimensionPixelSize( 143 R.dimen.blur_size_thin_outline); 144 mShadowFilter = new BlurMaskFilter(shadowSize, BlurMaskFilter.Blur.OUTER); 145 mShapePath = ThemeManager.INSTANCE.get(context).getIconShape().getPath(mNormalizedIconSize); 146 } 147 148 @Override onDraw(Canvas canvas)149 public void onDraw(Canvas canvas) { 150 int count = canvas.save(); 151 boolean isSlotMachineAnimRunning = mSlotMachineIcon != null; 152 if (!mIsPinned) { 153 drawRingEffect(canvas); 154 if (isSlotMachineAnimRunning) { 155 // Clip to to outside of the ring during the slot machine animation. 156 canvas.clipPath(mRingPath); 157 } 158 canvas.scale(1 - 2f * RING_EFFECT_RATIO, 1 - 2f * RING_EFFECT_RATIO, 159 getWidth() * .5f, getHeight() * .5f); 160 if (isSlotMachineAnimRunning) { 161 canvas.translate(0, mSlotMachineIconTranslationY); 162 mSlotMachineIcon.setBounds(mSlotIconBound); 163 mSlotMachineIcon.draw(canvas); 164 canvas.translate(0, getSlotMachineIconPlusSpacingSize()); 165 } 166 } 167 super.onDraw(canvas); 168 canvas.restoreToCount(count); 169 } 170 getSlotMachineIconPlusSpacingSize()171 private float getSlotMachineIconPlusSpacingSize() { 172 return getIconSize() + getOutlineOffsetY(); 173 } 174 175 @Override drawDotIfNecessary(Canvas canvas)176 protected void drawDotIfNecessary(Canvas canvas) { 177 mIsDrawingDot = true; 178 int count = canvas.save(); 179 canvas.translate(-getWidth() * RING_EFFECT_RATIO, -getHeight() * RING_EFFECT_RATIO); 180 canvas.scale(1 + 2 * RING_EFFECT_RATIO, 1 + 2 * RING_EFFECT_RATIO); 181 super.drawDotIfNecessary(canvas); 182 canvas.restoreToCount(count); 183 mIsDrawingDot = false; 184 } 185 186 /** 187 * Returns whether the newInfo differs from the current getTag(). 188 */ shouldAnimateIconChange(WorkspaceItemInfo newInfo)189 private boolean shouldAnimateIconChange(WorkspaceItemInfo newInfo) { 190 boolean changedIcons = getTag() instanceof WorkspaceItemInfo oldInfo 191 && oldInfo.getTargetComponent() != null 192 && newInfo.getTargetComponent() != null 193 && !oldInfo.getTargetComponent().equals(newInfo.getTargetComponent()); 194 return changedIcons && isShown(); 195 } 196 197 @Override applyIconAndLabel(ItemInfoWithIcon info)198 public void applyIconAndLabel(ItemInfoWithIcon info) { 199 super.applyIconAndLabel(info); 200 if (getIcon().isThemed()) { 201 mPlateColor.endColor = getResources().getColor(android.R.color.system_accent1_300); 202 } else { 203 float[] hctPlateColor = new float[3]; 204 ColorUtils.colorToM3HCT(mDotParams.appColor, hctPlateColor); 205 mPlateColor.endColor = ColorUtils.M3HCTToColor(hctPlateColor[0], 36, 85); 206 } 207 mPlateColor.onUpdate(); 208 } 209 210 /** 211 * Tries to apply the icon with animation and returns true if the icon was indeed animated 212 */ applyFromWorkspaceItemWithAnimation(WorkspaceItemInfo info, int staggerIndex)213 public boolean applyFromWorkspaceItemWithAnimation(WorkspaceItemInfo info, int staggerIndex) { 214 boolean animate = shouldAnimateIconChange(info); 215 Drawable oldIcon = getIcon(); 216 int oldPlateColor = mPlateColor.currentColor; 217 applyFromWorkspaceItem(info); 218 219 setContentDescription( 220 mIsPinned ? info.contentDescription : 221 getContext().getString(R.string.hotseat_prediction_content_description, 222 info.contentDescription)); 223 224 if (!animate) { 225 mPlateColor.startColor = mPlateColor.endColor; 226 mPlateColor.progress.value = 1; 227 mPlateColor.onUpdate(); 228 } else { 229 mPlateColor.startColor = oldPlateColor; 230 mPlateColor.progress.value = 0; 231 mPlateColor.onUpdate(); 232 233 AnimatorSet changeIconAnim = new AnimatorSet(); 234 235 ObjectAnimator plateColorAnim = 236 ObjectAnimator.ofFloat(mPlateColor.progress, AnimatedFloat.VALUE, 0, 1); 237 plateColorAnim.setAutoCancel(true); 238 changeIconAnim.play(plateColorAnim); 239 240 if (!mIsPinned && oldIcon != null) { 241 // Play the slot machine icon 242 mSlotMachineIcon = oldIcon; 243 244 float finalTrans = -getSlotMachineIconPlusSpacingSize(); 245 Keyframe[] keyframes = new Keyframe[] { 246 Keyframe.ofFloat(0f, 0f), 247 Keyframe.ofFloat(0.82f, finalTrans - getOutlineOffsetY() / 2f), // Overshoot 248 Keyframe.ofFloat(1f, finalTrans) // Ease back into the final position 249 }; 250 keyframes[1].setInterpolator(ACCELERATE_DECELERATE); 251 keyframes[2].setInterpolator(ACCELERATE_DECELERATE); 252 253 ObjectAnimator slotMachineAnim = ObjectAnimator.ofPropertyValuesHolder(this, 254 PropertyValuesHolder.ofKeyframe(SLOT_MACHINE_TRANSLATION_Y, keyframes)); 255 slotMachineAnim.addListener(AnimatorListeners.forEndCallback(() -> { 256 mSlotMachineIcon = null; 257 mSlotMachineIconTranslationY = 0; 258 invalidate(); 259 })); 260 slotMachineAnim.setAutoCancel(true); 261 changeIconAnim.play(slotMachineAnim); 262 } 263 264 changeIconAnim.setStartDelay(staggerIndex * ICON_CHANGE_ANIM_STAGGER); 265 changeIconAnim.setDuration(ICON_CHANGE_ANIM_DURATION).start(); 266 } 267 return animate; 268 } 269 270 /** 271 * Removes prediction ring from app icon 272 */ pin(WorkspaceItemInfo info)273 public void pin(WorkspaceItemInfo info) { 274 if (mIsPinned) return; 275 mIsPinned = true; 276 applyFromWorkspaceItem(info); 277 setOnLongClickListener(ItemLongClickListener.INSTANCE_WORKSPACE); 278 ((CellLayoutLayoutParams) getLayoutParams()).canReorder = true; 279 invalidate(); 280 } 281 282 /** 283 * prepares prediction icon for usage after bind 284 */ finishBinding(OnLongClickListener longClickListener)285 public void finishBinding(OnLongClickListener longClickListener) { 286 setOnLongClickListener(longClickListener); 287 ((CellLayoutLayoutParams) getLayoutParams()).canReorder = false; 288 setTextVisibility(false); 289 verifyHighRes(); 290 } 291 292 @Override getIconBounds(Rect outBounds)293 public void getIconBounds(Rect outBounds) { 294 super.getIconBounds(outBounds); 295 if (!mIsPinned && !mIsDrawingDot) { 296 int predictionInset = (int) (getIconSize() * RING_EFFECT_RATIO); 297 outBounds.inset(predictionInset, predictionInset); 298 } 299 } 300 isPinned()301 public boolean isPinned() { 302 return mIsPinned; 303 } 304 getOutlineOffsetX()305 private int getOutlineOffsetX() { 306 int measuredWidth = getMeasuredWidth(); 307 if (mDisplay != DISPLAY_TASKBAR) { 308 Log.d("b/387844520", "getOutlineOffsetX: measured width = " + measuredWidth 309 + ", mNormalizedIconSize = " + mNormalizedIconSize 310 + ", last updated width = " + mWidth); 311 } 312 return (mWidth - mNormalizedIconSize) / 2; 313 } 314 getOutlineOffsetY()315 private int getOutlineOffsetY() { 316 if (mDisplay != DISPLAY_TASKBAR) { 317 return getPaddingTop() + mDeviceProfile.folderIconOffsetYPx; 318 } 319 return (getMeasuredHeight() - mNormalizedIconSize) / 2; 320 } 321 322 @Override onSizeChanged(int w, int h, int oldw, int oldh)323 protected void onSizeChanged(int w, int h, int oldw, int oldh) { 324 super.onSizeChanged(w, h, oldw, oldh); 325 mWidth = w; 326 mSlotIconBound.offsetTo((w - getIconSize()) / 2, (h - getIconSize()) / 2); 327 if (mDisplay != DISPLAY_TASKBAR) { 328 Log.d("b/387844520", "calling updateRingPath from onSizeChanged"); 329 } 330 updateRingPath(); 331 } 332 333 @Override setTag(Object tag)334 public void setTag(Object tag) { 335 super.setTag(tag); 336 updateRingPath(); 337 } 338 updateRingPath()339 private void updateRingPath() { 340 mRingPath.reset(); 341 mTmpMatrix.reset(); 342 mTmpMatrix.setTranslate(getOutlineOffsetX(), getOutlineOffsetY()); 343 mRingPath.addPath(mShapePath, mTmpMatrix); 344 345 FastBitmapDrawable icon = getIcon(); 346 if (icon != null && icon.getBadge() != null) { 347 float outlineSize = mNormalizedIconSize * RING_EFFECT_RATIO; 348 float iconSize = getIconSize() * (1 - 2 * RING_EFFECT_RATIO); 349 float badgeSize = LauncherIcons.getBadgeSizeForIconSize((int) iconSize) + outlineSize; 350 float scale = badgeSize / mNormalizedIconSize; 351 mTmpMatrix.postTranslate(mNormalizedIconSize, mNormalizedIconSize); 352 mTmpMatrix.preScale(scale, scale); 353 mTmpMatrix.preTranslate(-mNormalizedIconSize, -mNormalizedIconSize); 354 mRingPath.addPath(mShapePath, mTmpMatrix); 355 } 356 invalidate(); 357 } 358 359 @Override setForceHideRing(boolean forceHideRing)360 public void setForceHideRing(boolean forceHideRing) { 361 if (mForceHideRing == forceHideRing) { 362 return; 363 } 364 mForceHideRing = forceHideRing; 365 366 if (forceHideRing) { 367 invalidate(); 368 } else { 369 animateRingScale(RING_SCALE_START_VALUE, 1); 370 } 371 } 372 cancelRingScaleAnim()373 private void cancelRingScaleAnim() { 374 if (mRingScaleAnim != null) { 375 mRingScaleAnim.cancel(); 376 } 377 } 378 animateRingScale(float... ringScale)379 private void animateRingScale(float... ringScale) { 380 cancelRingScaleAnim(); 381 mRingScaleAnim = ObjectAnimator.ofFloat(this, RING_SCALE_PROPERTY, ringScale); 382 mRingScaleAnim.addListener(new AnimatorListenerAdapter() { 383 @Override 384 public void onAnimationEnd(Animator animation) { 385 mRingScaleAnim = null; 386 } 387 }); 388 mRingScaleAnim.start(); 389 } 390 drawRingEffect(Canvas canvas)391 private void drawRingEffect(Canvas canvas) { 392 // Don't draw ring effect if item is about to be dragged or if the icon is not visible. 393 if (mDrawForDrag || !mIsIconVisible || mForceHideRing) { 394 return; 395 } 396 mIconRingPaint.setColor(RING_SHADOW_COLOR); 397 mIconRingPaint.setMaskFilter(mShadowFilter); 398 int count = canvas.save(); 399 if (Flags.enableLauncherIconShapes()) { 400 // Scale canvas properly to for ring to be inner stroke and not exceed bounds. 401 // Since STROKE draws half on either side of Path, scale canvas down by 1x stroke ratio. 402 canvas.scale( 403 mRingScale * (1f - RING_EFFECT_RATIO), 404 mRingScale * (1f - RING_EFFECT_RATIO), 405 getWidth() / 2f, 406 getHeight() / 2f); 407 } else if (Float.compare(1, mRingScale) != 0) { 408 canvas.scale(mRingScale, mRingScale, getWidth() / 2f, getHeight() / 2f); 409 } 410 // Draw ring shadow around canvas. 411 canvas.drawPath(mRingPath, mIconRingPaint); 412 mIconRingPaint.setColor(mPlateColor.currentColor); 413 if (Flags.enableLauncherIconShapes()) { 414 mIconRingPaint.setStrokeWidth(getWidth() * RING_EFFECT_RATIO); 415 // Using FILL_AND_STROKE as there is still some gap to fill, 416 // between inner curve of ring / outer curve of icon. 417 mIconRingPaint.setStyle(Paint.Style.FILL_AND_STROKE); 418 } 419 mIconRingPaint.setMaskFilter(null); 420 // Draw ring around canvas. 421 canvas.drawPath(mRingPath, mIconRingPaint); 422 canvas.restoreToCount(count); 423 } 424 425 @Override setIconDisabled(boolean isDisabled)426 public void setIconDisabled(boolean isDisabled) { 427 super.setIconDisabled(isDisabled); 428 mIconRingPaint.setColorFilter(isDisabled ? getDisabledColorFilter() : null); 429 invalidate(); 430 } 431 432 @Override setItemInfo(ItemInfoWithIcon itemInfo)433 protected void setItemInfo(ItemInfoWithIcon itemInfo) { 434 super.setItemInfo(itemInfo); 435 setIconDisabled(itemInfo.isDisabled()); 436 } 437 438 @Override getSourceVisualDragBounds(Rect bounds)439 public void getSourceVisualDragBounds(Rect bounds) { 440 super.getSourceVisualDragBounds(bounds); 441 if (!mIsPinned) { 442 int internalSize = (int) (bounds.width() * RING_EFFECT_RATIO); 443 bounds.inset(internalSize, internalSize); 444 } 445 } 446 447 @Override prepareDrawDragView()448 public SafeCloseable prepareDrawDragView() { 449 mDrawForDrag = true; 450 invalidate(); 451 SafeCloseable r = super.prepareDrawDragView(); 452 return () -> { 453 r.close(); 454 mDrawForDrag = false; 455 }; 456 } 457 458 /** 459 * Creates and returns a new instance of PredictedAppIcon from WorkspaceItemInfo 460 */ createIcon(ViewGroup parent, WorkspaceItemInfo info)461 public static PredictedAppIcon createIcon(ViewGroup parent, WorkspaceItemInfo info) { 462 PredictedAppIcon icon = (PredictedAppIcon) LayoutInflater.from(parent.getContext()) 463 .inflate(R.layout.predicted_app_icon, parent, false); 464 icon.applyFromWorkspaceItem(info); 465 Launcher launcher = Launcher.getLauncher(parent.getContext()); 466 icon.setOnClickListener(launcher.getItemOnClickListener()); 467 icon.setOnFocusChangeListener(launcher.getFocusHandler()); 468 return icon; 469 } 470 471 private class AnimColorHolder { 472 473 public final AnimatedFloat progress = new AnimatedFloat(this::onUpdate, 1); 474 public final ArgbEvaluator evaluator = ArgbEvaluator.getInstance(); 475 public Integer startColor = 0; 476 public Integer endColor = 0; 477 478 public int currentColor = 0; 479 onUpdate()480 private void onUpdate() { 481 currentColor = (Integer) evaluator.evaluate(progress.value, startColor, endColor); 482 invalidate(); 483 } 484 } 485 486 /** 487 * Draws Predicted Icon outline on cell layout 488 */ 489 public static class PredictedIconOutlineDrawing extends DelegatedCellDrawing { 490 491 private final PredictedAppIcon mIcon; 492 private final Paint mOutlinePaint = new Paint(Paint.ANTI_ALIAS_FLAG); 493 PredictedIconOutlineDrawing(int cellX, int cellY, PredictedAppIcon icon)494 public PredictedIconOutlineDrawing(int cellX, int cellY, PredictedAppIcon icon) { 495 mDelegateCellX = cellX; 496 mDelegateCellY = cellY; 497 mIcon = icon; 498 mOutlinePaint.setStyle(Paint.Style.FILL); 499 mOutlinePaint.setColor(Color.argb(24, 245, 245, 245)); 500 } 501 502 /** 503 * Draws predicted app icon outline under CellLayout 504 */ 505 @Override drawUnderItem(Canvas canvas)506 public void drawUnderItem(Canvas canvas) { 507 canvas.save(); 508 canvas.translate(mIcon.getOutlineOffsetX(), mIcon.getOutlineOffsetY()); 509 canvas.drawPath(mIcon.mShapePath, mOutlinePaint); 510 canvas.restore(); 511 } 512 513 /** 514 * Draws PredictedAppIcon outline over CellLayout 515 */ 516 @Override drawOverItem(Canvas canvas)517 public void drawOverItem(Canvas canvas) { 518 // Does nothing 519 } 520 } 521 } 522