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.BitmapInfo.FLAG_THEMED; 20 import static com.android.launcher3.icons.FastBitmapDrawable.getDisabledColorFilter; 21 22 import android.animation.Animator; 23 import android.animation.AnimatorSet; 24 import android.animation.ArgbEvaluator; 25 import android.animation.Keyframe; 26 import android.animation.ObjectAnimator; 27 import android.animation.PropertyValuesHolder; 28 import android.animation.ValueAnimator; 29 import android.annotation.Nullable; 30 import android.content.Context; 31 import android.graphics.BlurMaskFilter; 32 import android.graphics.Canvas; 33 import android.graphics.Color; 34 import android.graphics.Matrix; 35 import android.graphics.Paint; 36 import android.graphics.Path; 37 import android.graphics.Rect; 38 import android.graphics.drawable.Drawable; 39 import android.os.Process; 40 import android.util.AttributeSet; 41 import android.util.FloatProperty; 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.Launcher; 49 import com.android.launcher3.LauncherSettings; 50 import com.android.launcher3.R; 51 import com.android.launcher3.anim.AnimatorListeners; 52 import com.android.launcher3.celllayout.CellLayoutLayoutParams; 53 import com.android.launcher3.celllayout.DelegatedCellDrawing; 54 import com.android.launcher3.icons.BitmapInfo; 55 import com.android.launcher3.icons.GraphicsUtils; 56 import com.android.launcher3.icons.IconNormalizer; 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 import java.util.ArrayList; 66 import java.util.Collections; 67 import java.util.List; 68 69 /** 70 * A BubbleTextView with a ring around it's drawable 71 */ 72 public class PredictedAppIcon extends DoubleShadowBubbleTextView { 73 74 private static final int RING_SHADOW_COLOR = 0x99000000; 75 private static final float RING_EFFECT_RATIO = 0.095f; 76 77 private static final long ICON_CHANGE_ANIM_DURATION = 360; 78 private static final long ICON_CHANGE_ANIM_STAGGER = 50; 79 80 boolean mIsDrawingDot = false; 81 private final DeviceProfile mDeviceProfile; 82 private final Paint mIconRingPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 83 private final Path mRingPath = new Path(); 84 private final int mNormalizedIconSize; 85 private final Path mShapePath; 86 private final Matrix mTmpMatrix = new Matrix(); 87 88 private final BlurMaskFilter mShadowFilter; 89 90 private boolean mIsPinned = false; 91 private int mPlateColor; 92 boolean mDrawForDrag = false; 93 94 // Used for the "slot-machine" education animation. 95 private List<Drawable> mSlotMachineIcons; 96 private Animator mSlotMachineAnim; 97 private float mSlotMachineIconTranslationY; 98 99 private static final FloatProperty<PredictedAppIcon> SLOT_MACHINE_TRANSLATION_Y = 100 new FloatProperty<PredictedAppIcon>("slotMachineTranslationY") { 101 @Override 102 public void setValue(PredictedAppIcon predictedAppIcon, float transY) { 103 predictedAppIcon.mSlotMachineIconTranslationY = transY; 104 predictedAppIcon.invalidate(); 105 } 106 107 @Override 108 public Float get(PredictedAppIcon predictedAppIcon) { 109 return predictedAppIcon.mSlotMachineIconTranslationY; 110 } 111 }; 112 PredictedAppIcon(Context context)113 public PredictedAppIcon(Context context) { 114 this(context, null, 0); 115 } 116 PredictedAppIcon(Context context, AttributeSet attrs)117 public PredictedAppIcon(Context context, AttributeSet attrs) { 118 this(context, attrs, 0); 119 } 120 PredictedAppIcon(Context context, AttributeSet attrs, int defStyle)121 public PredictedAppIcon(Context context, AttributeSet attrs, int defStyle) { 122 super(context, attrs, defStyle); 123 mDeviceProfile = ActivityContext.lookupContext(context).getDeviceProfile(); 124 mNormalizedIconSize = IconNormalizer.getNormalizedCircleSize(getIconSize()); 125 int shadowSize = context.getResources().getDimensionPixelSize( 126 R.dimen.blur_size_thin_outline); 127 mShadowFilter = new BlurMaskFilter(shadowSize, BlurMaskFilter.Blur.OUTER); 128 mShapePath = GraphicsUtils.getShapePath(context, mNormalizedIconSize); 129 } 130 131 @Override onDraw(Canvas canvas)132 public void onDraw(Canvas canvas) { 133 int count = canvas.save(); 134 boolean isSlotMachineAnimRunning = mSlotMachineAnim != null; 135 if (!mIsPinned) { 136 drawEffect(canvas); 137 if (isSlotMachineAnimRunning) { 138 // Clip to to outside of the ring during the slot machine animation. 139 canvas.clipPath(mRingPath); 140 } 141 canvas.translate(getWidth() * RING_EFFECT_RATIO, getHeight() * RING_EFFECT_RATIO); 142 canvas.scale(1 - 2 * RING_EFFECT_RATIO, 1 - 2 * RING_EFFECT_RATIO); 143 } 144 if (isSlotMachineAnimRunning) { 145 drawSlotMachineIcons(canvas); 146 } else { 147 super.onDraw(canvas); 148 } 149 canvas.restoreToCount(count); 150 } 151 drawSlotMachineIcons(Canvas canvas)152 private void drawSlotMachineIcons(Canvas canvas) { 153 canvas.translate((getWidth() - getIconSize()) / 2f, 154 (getHeight() - getIconSize()) / 2f + mSlotMachineIconTranslationY); 155 for (Drawable icon : mSlotMachineIcons) { 156 icon.setBounds(0, 0, getIconSize(), getIconSize()); 157 icon.draw(canvas); 158 canvas.translate(0, getSlotMachineIconPlusSpacingSize()); 159 } 160 } 161 getSlotMachineIconPlusSpacingSize()162 private float getSlotMachineIconPlusSpacingSize() { 163 return getIconSize() + getOutlineOffsetY(); 164 } 165 166 @Override drawDotIfNecessary(Canvas canvas)167 protected void drawDotIfNecessary(Canvas canvas) { 168 mIsDrawingDot = true; 169 int count = canvas.save(); 170 canvas.translate(-getWidth() * RING_EFFECT_RATIO, -getHeight() * RING_EFFECT_RATIO); 171 canvas.scale(1 + 2 * RING_EFFECT_RATIO, 1 + 2 * RING_EFFECT_RATIO); 172 super.drawDotIfNecessary(canvas); 173 canvas.restoreToCount(count); 174 mIsDrawingDot = false; 175 } 176 177 @Override applyFromWorkspaceItem(WorkspaceItemInfo info, boolean animate, int staggerIndex)178 public void applyFromWorkspaceItem(WorkspaceItemInfo info, boolean animate, int staggerIndex) { 179 // Create the slot machine animation first, since it uses the current icon to start. 180 Animator slotMachineAnim = animate 181 ? createSlotMachineAnim(Collections.singletonList(info.bitmap), false) 182 : null; 183 super.applyFromWorkspaceItem(info, animate, staggerIndex); 184 int oldPlateColor = mPlateColor; 185 186 int newPlateColor; 187 if (getIcon().isThemed()) { 188 newPlateColor = getResources().getColor(android.R.color.system_accent1_300); 189 } else { 190 float[] hctPlateColor = new float[3]; 191 ColorUtils.colorToM3HCT(mDotParams.appColor, hctPlateColor); 192 newPlateColor = ColorUtils.M3HCTToColor(hctPlateColor[0], 36, 85); 193 } 194 195 if (!animate) { 196 mPlateColor = newPlateColor; 197 } 198 if (mIsPinned) { 199 setContentDescription(info.contentDescription); 200 } else { 201 setContentDescription( 202 getContext().getString(R.string.hotseat_prediction_content_description, 203 info.contentDescription)); 204 } 205 206 if (animate) { 207 ValueAnimator plateColorAnim = ValueAnimator.ofObject(new ArgbEvaluator(), 208 oldPlateColor, newPlateColor); 209 plateColorAnim.addUpdateListener(valueAnimator -> { 210 mPlateColor = (int) valueAnimator.getAnimatedValue(); 211 invalidate(); 212 }); 213 AnimatorSet changeIconAnim = new AnimatorSet(); 214 if (slotMachineAnim != null) { 215 changeIconAnim.play(slotMachineAnim); 216 } 217 changeIconAnim.play(plateColorAnim); 218 changeIconAnim.setStartDelay(staggerIndex * ICON_CHANGE_ANIM_STAGGER); 219 changeIconAnim.setDuration(ICON_CHANGE_ANIM_DURATION).start(); 220 } 221 } 222 223 /** 224 * Returns an Animator that translates the given icons in a "slot-machine" fashion, beginning 225 * and ending with the original icon. 226 */ createSlotMachineAnim(List<BitmapInfo> iconsToAnimate)227 public @Nullable Animator createSlotMachineAnim(List<BitmapInfo> iconsToAnimate) { 228 return createSlotMachineAnim(iconsToAnimate, true); 229 } 230 231 /** 232 * Returns an Animator that translates the given icons in a "slot-machine" fashion, beginning 233 * with the original icon, then cycling through the given icons, optionally ending back with 234 * the original icon. 235 * @param endWithOriginalIcon Whether we should land back on the icon we started with, rather 236 * than the last item in iconsToAnimate. 237 */ createSlotMachineAnim(List<BitmapInfo> iconsToAnimate, boolean endWithOriginalIcon)238 public @Nullable Animator createSlotMachineAnim(List<BitmapInfo> iconsToAnimate, 239 boolean endWithOriginalIcon) { 240 if (mIsPinned || iconsToAnimate == null || iconsToAnimate.isEmpty()) { 241 return null; 242 } 243 if (mSlotMachineAnim != null) { 244 mSlotMachineAnim.end(); 245 } 246 247 // Bookend the other animating icons with the original icon on both ends. 248 mSlotMachineIcons = new ArrayList<>(iconsToAnimate.size() + 2); 249 mSlotMachineIcons.add(getIcon()); 250 iconsToAnimate.stream() 251 .map(iconInfo -> iconInfo.newIcon(mContext, FLAG_THEMED)) 252 .forEach(mSlotMachineIcons::add); 253 if (endWithOriginalIcon) { 254 mSlotMachineIcons.add(getIcon()); 255 } 256 257 float finalTrans = -getSlotMachineIconPlusSpacingSize() * (mSlotMachineIcons.size() - 1); 258 Keyframe[] keyframes = new Keyframe[] { 259 Keyframe.ofFloat(0f, 0f), 260 Keyframe.ofFloat(0.82f, finalTrans - getOutlineOffsetY() / 2f), // Overshoot 261 Keyframe.ofFloat(1f, finalTrans) // Ease back into the final position 262 }; 263 keyframes[1].setInterpolator(ACCELERATE_DECELERATE); 264 keyframes[2].setInterpolator(ACCELERATE_DECELERATE); 265 266 mSlotMachineAnim = ObjectAnimator.ofPropertyValuesHolder(this, 267 PropertyValuesHolder.ofKeyframe(SLOT_MACHINE_TRANSLATION_Y, keyframes)); 268 mSlotMachineAnim.addListener(AnimatorListeners.forEndCallback(() -> { 269 mSlotMachineIcons = null; 270 mSlotMachineAnim = null; 271 mSlotMachineIconTranslationY = 0; 272 invalidate(); 273 })); 274 return mSlotMachineAnim; 275 } 276 277 /** 278 * Removes prediction ring from app icon 279 */ pin(WorkspaceItemInfo info)280 public void pin(WorkspaceItemInfo info) { 281 if (mIsPinned) return; 282 mIsPinned = true; 283 applyFromWorkspaceItem(info); 284 setOnLongClickListener(ItemLongClickListener.INSTANCE_WORKSPACE); 285 ((CellLayoutLayoutParams) getLayoutParams()).canReorder = true; 286 invalidate(); 287 } 288 289 /** 290 * prepares prediction icon for usage after bind 291 */ finishBinding(OnLongClickListener longClickListener)292 public void finishBinding(OnLongClickListener longClickListener) { 293 setOnLongClickListener(longClickListener); 294 ((CellLayoutLayoutParams) getLayoutParams()).canReorder = false; 295 setTextVisibility(false); 296 verifyHighRes(); 297 } 298 299 @Override getIconBounds(Rect outBounds)300 public void getIconBounds(Rect outBounds) { 301 super.getIconBounds(outBounds); 302 if (!mIsPinned && !mIsDrawingDot) { 303 int predictionInset = (int) (getIconSize() * RING_EFFECT_RATIO); 304 outBounds.inset(predictionInset, predictionInset); 305 } 306 } 307 isPinned()308 public boolean isPinned() { 309 return mIsPinned; 310 } 311 getOutlineOffsetX()312 private int getOutlineOffsetX() { 313 return (getMeasuredWidth() - mNormalizedIconSize) / 2; 314 } 315 getOutlineOffsetY()316 private int getOutlineOffsetY() { 317 if (mDisplay != DISPLAY_TASKBAR) { 318 return getPaddingTop() + mDeviceProfile.folderIconOffsetYPx; 319 } 320 return (getMeasuredHeight() - mNormalizedIconSize) / 2; 321 } 322 323 @Override onSizeChanged(int w, int h, int oldw, int oldh)324 protected void onSizeChanged(int w, int h, int oldw, int oldh) { 325 super.onSizeChanged(w, h, oldw, oldh); 326 updateRingPath(); 327 } 328 329 @Override setTag(Object tag)330 public void setTag(Object tag) { 331 super.setTag(tag); 332 updateRingPath(); 333 } 334 updateRingPath()335 private void updateRingPath() { 336 boolean isBadged = false; 337 if (getTag() instanceof WorkspaceItemInfo) { 338 WorkspaceItemInfo info = (WorkspaceItemInfo) getTag(); 339 isBadged = !Process.myUserHandle().equals(info.user) 340 || info.itemType == LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT; 341 } 342 343 mRingPath.reset(); 344 mTmpMatrix.setTranslate(getOutlineOffsetX(), getOutlineOffsetY()); 345 346 mRingPath.addPath(mShapePath, mTmpMatrix); 347 if (isBadged) { 348 float outlineSize = mNormalizedIconSize * RING_EFFECT_RATIO; 349 float iconSize = getIconSize() * (1 - 2 * RING_EFFECT_RATIO); 350 float badgeSize = LauncherIcons.getBadgeSizeForIconSize((int) iconSize) + outlineSize; 351 float scale = badgeSize / mNormalizedIconSize; 352 mTmpMatrix.postTranslate(mNormalizedIconSize, mNormalizedIconSize); 353 mTmpMatrix.preScale(scale, scale); 354 mTmpMatrix.preTranslate(-mNormalizedIconSize, -mNormalizedIconSize); 355 mRingPath.addPath(mShapePath, mTmpMatrix); 356 } 357 } 358 drawEffect(Canvas canvas)359 private void drawEffect(Canvas canvas) { 360 // Don't draw ring effect if item is about to be dragged. 361 if (mDrawForDrag) { 362 return; 363 } 364 mIconRingPaint.setColor(RING_SHADOW_COLOR); 365 mIconRingPaint.setMaskFilter(mShadowFilter); 366 canvas.drawPath(mRingPath, mIconRingPaint); 367 mIconRingPaint.setColor(mPlateColor); 368 mIconRingPaint.setMaskFilter(null); 369 canvas.drawPath(mRingPath, mIconRingPaint); 370 } 371 372 @Override setIconDisabled(boolean isDisabled)373 public void setIconDisabled(boolean isDisabled) { 374 super.setIconDisabled(isDisabled); 375 mIconRingPaint.setColorFilter(isDisabled ? getDisabledColorFilter() : null); 376 invalidate(); 377 } 378 379 @Override setItemInfo(ItemInfoWithIcon itemInfo)380 protected void setItemInfo(ItemInfoWithIcon itemInfo) { 381 super.setItemInfo(itemInfo); 382 setIconDisabled(itemInfo.isDisabled()); 383 } 384 385 @Override getSourceVisualDragBounds(Rect bounds)386 public void getSourceVisualDragBounds(Rect bounds) { 387 super.getSourceVisualDragBounds(bounds); 388 if (!mIsPinned) { 389 int internalSize = (int) (bounds.width() * RING_EFFECT_RATIO); 390 bounds.inset(internalSize, internalSize); 391 } 392 } 393 394 @Override prepareDrawDragView()395 public SafeCloseable prepareDrawDragView() { 396 mDrawForDrag = true; 397 invalidate(); 398 SafeCloseable r = super.prepareDrawDragView(); 399 return () -> { 400 r.close(); 401 mDrawForDrag = false; 402 }; 403 } 404 405 /** 406 * Creates and returns a new instance of PredictedAppIcon from WorkspaceItemInfo 407 */ createIcon(ViewGroup parent, WorkspaceItemInfo info)408 public static PredictedAppIcon createIcon(ViewGroup parent, WorkspaceItemInfo info) { 409 PredictedAppIcon icon = (PredictedAppIcon) LayoutInflater.from(parent.getContext()) 410 .inflate(R.layout.predicted_app_icon, parent, false); 411 icon.applyFromWorkspaceItem(info); 412 Launcher launcher = Launcher.getLauncher(parent.getContext()); 413 icon.setOnClickListener(launcher.getItemOnClickListener()); 414 icon.setOnFocusChangeListener(launcher.getFocusHandler()); 415 return icon; 416 } 417 418 /** 419 * Draws Predicted Icon outline on cell layout 420 */ 421 public static class PredictedIconOutlineDrawing extends DelegatedCellDrawing { 422 423 private final PredictedAppIcon mIcon; 424 private final Paint mOutlinePaint = new Paint(Paint.ANTI_ALIAS_FLAG); 425 PredictedIconOutlineDrawing(int cellX, int cellY, PredictedAppIcon icon)426 public PredictedIconOutlineDrawing(int cellX, int cellY, PredictedAppIcon icon) { 427 mDelegateCellX = cellX; 428 mDelegateCellY = cellY; 429 mIcon = icon; 430 mOutlinePaint.setStyle(Paint.Style.FILL); 431 mOutlinePaint.setColor(Color.argb(24, 245, 245, 245)); 432 } 433 434 /** 435 * Draws predicted app icon outline under CellLayout 436 */ 437 @Override drawUnderItem(Canvas canvas)438 public void drawUnderItem(Canvas canvas) { 439 canvas.save(); 440 canvas.translate(mIcon.getOutlineOffsetX(), mIcon.getOutlineOffsetY()); 441 canvas.drawPath(mIcon.mShapePath, mOutlinePaint); 442 canvas.restore(); 443 } 444 445 /** 446 * Draws PredictedAppIcon outline over CellLayout 447 */ 448 @Override drawOverItem(Canvas canvas)449 public void drawOverItem(Canvas canvas) { 450 // Does nothing 451 } 452 } 453 } 454