1 /* 2 * Copyright (C) 2023 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.quickstep.util; 17 18 import android.animation.Animator; 19 import android.animation.AnimatorListenerAdapter; 20 import android.annotation.ColorInt; 21 import android.annotation.Nullable; 22 import android.graphics.Canvas; 23 import android.graphics.Color; 24 import android.graphics.Paint; 25 import android.graphics.Rect; 26 import android.view.View; 27 import android.view.animation.Interpolator; 28 29 import androidx.annotation.NonNull; 30 import androidx.annotation.Px; 31 32 import com.android.app.animation.Interpolators; 33 import com.android.launcher3.anim.AnimatedFloat; 34 import com.android.launcher3.anim.AnimatorListeners; 35 36 /** 37 * Utility class for drawing a rounded-rect border around a view. 38 * <p> 39 * To use this class: 40 * 1. Create an instance in the target view. NOTE: The border will animate outwards from the 41 * provided border bounds. See {@link SimpleParams} and {@link ScalingParams} to determine 42 * which would be best for your target view. 43 * 2. Override the target view's {@link android.view.View#draw(Canvas)} method and call 44 * {@link BorderAnimator#drawBorder(Canvas)} after {@code super.draw(canvas)}. 45 * 3. Call {@link BorderAnimator#buildAnimator(boolean)} and start the animation or call 46 * {@link BorderAnimator#setBorderVisible(boolean)} where appropriate. 47 */ 48 public final class BorderAnimator { 49 50 public static final int DEFAULT_BORDER_COLOR = Color.WHITE; 51 52 private static final long DEFAULT_APPEARANCE_ANIMATION_DURATION_MS = 300; 53 private static final long DEFAULT_DISAPPEARANCE_ANIMATION_DURATION_MS = 133; 54 private static final Interpolator DEFAULT_INTERPOLATOR = Interpolators.EMPHASIZED_DECELERATE; 55 56 @NonNull private final AnimatedFloat mBorderAnimationProgress = new AnimatedFloat( 57 this::updateOutline); 58 @Px private final int mBorderRadiusPx; 59 @NonNull private final BorderAnimationParams mBorderAnimationParams; 60 private final long mAppearanceDurationMs; 61 private final long mDisappearanceDurationMs; 62 @NonNull private final Interpolator mInterpolator; 63 @NonNull private final Paint mBorderPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 64 65 @Nullable private Animator mRunningBorderAnimation; 66 BorderAnimator( @x int borderRadiusPx, @ColorInt int borderColor, @NonNull BorderAnimationParams borderAnimationParams)67 public BorderAnimator( 68 @Px int borderRadiusPx, 69 @ColorInt int borderColor, 70 @NonNull BorderAnimationParams borderAnimationParams) { 71 this(borderRadiusPx, 72 borderColor, 73 borderAnimationParams, 74 DEFAULT_APPEARANCE_ANIMATION_DURATION_MS, 75 DEFAULT_DISAPPEARANCE_ANIMATION_DURATION_MS, 76 DEFAULT_INTERPOLATOR); 77 } 78 79 /** 80 * @param borderRadiusPx the radius of the border's corners, in pixels 81 * @param borderColor the border's color 82 * @param borderAnimationParams params for handling different target view layout situation. 83 * @param appearanceDurationMs appearance animation duration, in milliseconds 84 * @param disappearanceDurationMs disappearance animation duration, in milliseconds 85 * @param interpolator animation interpolator 86 */ BorderAnimator( @x int borderRadiusPx, @ColorInt int borderColor, @NonNull BorderAnimationParams borderAnimationParams, long appearanceDurationMs, long disappearanceDurationMs, @NonNull Interpolator interpolator)87 public BorderAnimator( 88 @Px int borderRadiusPx, 89 @ColorInt int borderColor, 90 @NonNull BorderAnimationParams borderAnimationParams, 91 long appearanceDurationMs, 92 long disappearanceDurationMs, 93 @NonNull Interpolator interpolator) { 94 mBorderRadiusPx = borderRadiusPx; 95 mBorderAnimationParams = borderAnimationParams; 96 mAppearanceDurationMs = appearanceDurationMs; 97 mDisappearanceDurationMs = disappearanceDurationMs; 98 mInterpolator = interpolator; 99 100 mBorderPaint.setColor(borderColor); 101 mBorderPaint.setStyle(Paint.Style.STROKE); 102 mBorderPaint.setAlpha(0); 103 } 104 updateOutline()105 private void updateOutline() { 106 float interpolatedProgress = mInterpolator.getInterpolation( 107 mBorderAnimationProgress.value); 108 109 mBorderAnimationParams.setProgress(interpolatedProgress); 110 mBorderPaint.setAlpha(Math.round(255 * interpolatedProgress)); 111 mBorderPaint.setStrokeWidth(mBorderAnimationParams.getBorderWidth()); 112 mBorderAnimationParams.mTargetView.invalidate(); 113 } 114 115 /** 116 * Draws the border on the given canvas. 117 * <p> 118 * Call this method in the target view's {@link android.view.View#draw(Canvas)} method after 119 * calling super. 120 */ drawBorder(Canvas canvas)121 public void drawBorder(Canvas canvas) { 122 float alignmentAdjustment = mBorderAnimationParams.getAlignmentAdjustment(); 123 canvas.drawRoundRect( 124 /* left= */ mBorderAnimationParams.mBorderBounds.left + alignmentAdjustment, 125 /* top= */ mBorderAnimationParams.mBorderBounds.top + alignmentAdjustment, 126 /* right= */ mBorderAnimationParams.mBorderBounds.right - alignmentAdjustment, 127 /* bottom= */ mBorderAnimationParams.mBorderBounds.bottom - alignmentAdjustment, 128 /* rx= */ mBorderRadiusPx + mBorderAnimationParams.getRadiusAdjustment(), 129 /* ry= */ mBorderRadiusPx + mBorderAnimationParams.getRadiusAdjustment(), 130 /* paint= */ mBorderPaint); 131 } 132 133 /** 134 * Builds the border appearance/disappearance animation. 135 */ 136 @NonNull buildAnimator(boolean isAppearing)137 public Animator buildAnimator(boolean isAppearing) { 138 mRunningBorderAnimation = mBorderAnimationProgress.animateToValue(isAppearing ? 1f : 0f); 139 mRunningBorderAnimation.setDuration( 140 isAppearing ? mAppearanceDurationMs : mDisappearanceDurationMs); 141 142 mRunningBorderAnimation.addListener(new AnimatorListenerAdapter() { 143 @Override 144 public void onAnimationStart(Animator animation) { 145 mBorderAnimationParams.onShowBorder(); 146 } 147 }); 148 mRunningBorderAnimation.addListener( 149 AnimatorListeners.forEndCallback(() -> { 150 mRunningBorderAnimation = null; 151 if (isAppearing) { 152 return; 153 } 154 mBorderAnimationParams.onHideBorder(); 155 })); 156 157 return mRunningBorderAnimation; 158 } 159 160 /** 161 * Immediately shows/hides the border without an animation. 162 * <p> 163 * To animate the appearance/disappearance, see {@link BorderAnimator#buildAnimator(boolean)} 164 */ setBorderVisible(boolean visible)165 public void setBorderVisible(boolean visible) { 166 if (mRunningBorderAnimation != null) { 167 mRunningBorderAnimation.end(); 168 } 169 if (visible) { 170 mBorderAnimationParams.onShowBorder(); 171 } 172 mBorderAnimationProgress.updateValue(visible ? 1f : 0f); 173 if (!visible) { 174 mBorderAnimationParams.onHideBorder(); 175 } 176 } 177 178 /** 179 * Callback to update the border bounds when building this animation. 180 */ 181 public interface BorderBoundsBuilder { 182 183 /** 184 * Sets the given rect to the most up-to-date bounds. 185 */ updateBorderBounds(Rect rect)186 void updateBorderBounds(Rect rect); 187 } 188 189 /** 190 * Params for handling different target view layout situation. 191 */ 192 private abstract static class BorderAnimationParams { 193 194 @NonNull private final Rect mBorderBounds = new Rect(); 195 @NonNull private final BorderBoundsBuilder mBoundsBuilder; 196 197 @NonNull final View mTargetView; 198 @Px final int mBorderWidthPx; 199 200 private float mAnimationProgress = 0f; 201 @Nullable private View.OnLayoutChangeListener mLayoutChangeListener; 202 203 /** 204 * @param borderWidthPx the width of the border, in pixels 205 * @param boundsBuilder callback to update the border bounds 206 * @param targetView the view that will be drawing the border 207 */ BorderAnimationParams( @x int borderWidthPx, @NonNull BorderBoundsBuilder boundsBuilder, @NonNull View targetView)208 private BorderAnimationParams( 209 @Px int borderWidthPx, 210 @NonNull BorderBoundsBuilder boundsBuilder, 211 @NonNull View targetView) { 212 mBorderWidthPx = borderWidthPx; 213 mBoundsBuilder = boundsBuilder; 214 mTargetView = targetView; 215 } 216 setProgress(float progress)217 private void setProgress(float progress) { 218 mAnimationProgress = progress; 219 } 220 getBorderWidth()221 private float getBorderWidth() { 222 return mBorderWidthPx * mAnimationProgress; 223 } 224 getAlignmentAdjustment()225 float getAlignmentAdjustment() { 226 // Outset the border by half the width to create an outwards-growth animation 227 return (-getBorderWidth() / 2f) + getAlignmentAdjustmentInset(); 228 } 229 230 onShowBorder()231 void onShowBorder() { 232 if (mLayoutChangeListener == null) { 233 mLayoutChangeListener = 234 (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { 235 onShowBorder(); 236 mTargetView.invalidate(); 237 }; 238 mTargetView.addOnLayoutChangeListener(mLayoutChangeListener); 239 } 240 mBoundsBuilder.updateBorderBounds(mBorderBounds); 241 } 242 onHideBorder()243 void onHideBorder() { 244 if (mLayoutChangeListener != null) { 245 mTargetView.removeOnLayoutChangeListener(mLayoutChangeListener); 246 mLayoutChangeListener = null; 247 } 248 } 249 getAlignmentAdjustmentInset()250 abstract int getAlignmentAdjustmentInset(); 251 getRadiusAdjustment()252 abstract float getRadiusAdjustment(); 253 } 254 255 /** 256 * Use an instance of this {@link BorderAnimationParams} if the border can be drawn outside the 257 * target view's bounds without any additional logic. 258 */ 259 public static final class SimpleParams extends BorderAnimationParams { 260 SimpleParams( @x int borderWidthPx, @NonNull BorderBoundsBuilder boundsBuilder, @NonNull View targetView)261 public SimpleParams( 262 @Px int borderWidthPx, 263 @NonNull BorderBoundsBuilder boundsBuilder, 264 @NonNull View targetView) { 265 super(borderWidthPx, boundsBuilder, targetView); 266 } 267 268 @Override getAlignmentAdjustmentInset()269 int getAlignmentAdjustmentInset() { 270 return 0; 271 } 272 273 @Override getRadiusAdjustment()274 float getRadiusAdjustment() { 275 return -getAlignmentAdjustment(); 276 } 277 } 278 279 /** 280 * Use an instance of this {@link BorderAnimationParams} if the border would other be clipped by 281 * the target view's bound. 282 * <p> 283 * Note: using these params will set the scales and pivots of the 284 * container and content views, however will only reset the scales back to 1. 285 */ 286 public static final class ScalingParams extends BorderAnimationParams { 287 288 @NonNull private final View mContentView; 289 290 /** 291 * @param targetView the view that will be drawing the border. this view will be scaled up 292 * to make room for the border 293 * @param contentView the view around which the border will be drawn. this view will be 294 * scaled down reciprocally to keep its original size and location. 295 */ ScalingParams( @x int borderWidthPx, @NonNull BorderBoundsBuilder boundsBuilder, @NonNull View targetView, @NonNull View contentView)296 public ScalingParams( 297 @Px int borderWidthPx, 298 @NonNull BorderBoundsBuilder boundsBuilder, 299 @NonNull View targetView, 300 @NonNull View contentView) { 301 super(borderWidthPx, boundsBuilder, targetView); 302 mContentView = contentView; 303 } 304 305 @Override onShowBorder()306 void onShowBorder() { 307 super.onShowBorder(); 308 float width = mTargetView.getWidth(); 309 float height = mTargetView.getHeight(); 310 // Scale up just enough to make room for the border. Fail fast and fix the scaling 311 // onLayout. 312 float scaleX = width == 0 ? 1f : 1f + ((2 * mBorderWidthPx) / width); 313 float scaleY = height == 0 ? 1f : 1f + ((2 * mBorderWidthPx) / height); 314 315 mTargetView.setPivotX(width / 2); 316 mTargetView.setPivotY(height / 2); 317 mTargetView.setScaleX(scaleX); 318 mTargetView.setScaleY(scaleY); 319 320 mContentView.setPivotX(mContentView.getWidth() / 2f); 321 mContentView.setPivotY(mContentView.getHeight() / 2f); 322 mContentView.setScaleX(1f / scaleX); 323 mContentView.setScaleY(1f / scaleY); 324 } 325 326 @Override onHideBorder()327 void onHideBorder() { 328 super.onHideBorder(); 329 mTargetView.setPivotX(mTargetView.getWidth()); 330 mTargetView.setPivotY(mTargetView.getHeight()); 331 mTargetView.setScaleX(1f); 332 mTargetView.setScaleY(1f); 333 334 mContentView.setPivotX(mContentView.getWidth() / 2f); 335 mContentView.setPivotY(mContentView.getHeight() / 2f); 336 mContentView.setScaleX(1f); 337 mContentView.setScaleY(1f); 338 } 339 340 @Override getAlignmentAdjustmentInset()341 int getAlignmentAdjustmentInset() { 342 // Inset the border since we are scaling the container up 343 return mBorderWidthPx; 344 } 345 346 @Override getRadiusAdjustment()347 float getRadiusAdjustment() { 348 // Increase the radius since we are scaling the container up 349 return getAlignmentAdjustment(); 350 } 351 } 352 } 353