1 /*
<lambda>null2  * 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.annotation.ColorInt
20 import android.graphics.Canvas
21 import android.graphics.Color
22 import android.graphics.Paint
23 import android.graphics.Rect
24 import android.view.View
25 import android.view.View.OnLayoutChangeListener
26 import android.view.animation.Interpolator
27 import androidx.annotation.Px
28 import androidx.core.animation.doOnEnd
29 import androidx.core.animation.doOnStart
30 import com.android.app.animation.Interpolators
31 import com.android.launcher3.anim.AnimatedFloat
32 import kotlin.math.roundToInt
33 
34 /**
35  * Utility class for drawing a rounded-rect border around a view.
36  *
37  * To use this class:
38  * 1. Create an instance in the target view. NOTE: The border will animate outwards from the
39  *    provided border bounds.
40  * 2. Override the target view's [View.draw] method and call [drawBorder] after
41  *    `super.draw(canvas)`.
42  * 3. Call [buildAnimator] and start the animation or call [setBorderVisibility] where appropriate.
43  */
44 class BorderAnimator
45 private constructor(
46     @field:Px @param:Px private val borderRadiusPx: Int,
47     @ColorInt borderColor: Int,
48     private val borderAnimationParams: BorderAnimationParams,
49     private val appearanceDurationMs: Long,
50     private val disappearanceDurationMs: Long,
51     private val interpolator: Interpolator,
52 ) {
53     private val borderAnimationProgress = AnimatedFloat { _ -> updateOutline() }
54     private val borderPaint =
55         Paint(Paint.ANTI_ALIAS_FLAG).apply {
56             color = borderColor
57             style = Paint.Style.STROKE
58             alpha = 0
59         }
60     private var runningBorderAnimation: Animator? = null
61 
62     companion object {
63         const val DEFAULT_BORDER_COLOR = Color.WHITE
64         private const val DEFAULT_APPEARANCE_ANIMATION_DURATION_MS = 300L
65         private const val DEFAULT_DISAPPEARANCE_ANIMATION_DURATION_MS = 133L
66         private val DEFAULT_INTERPOLATOR = Interpolators.EMPHASIZED_DECELERATE
67 
68         /**
69          * Creates a BorderAnimator that simply draws the border outside the bound of the target
70          * view.
71          *
72          * Use this method if the border can be drawn outside the target view's bounds without any
73          * additional logic.
74          *
75          * @param borderRadiusPx the radius of the border's corners, in pixels
76          * @param borderWidthPx the width of the border, in pixels
77          * @param boundsBuilder callback to update the border bounds
78          * @param targetView the view that will be drawing the border
79          * @param borderColor the border's color
80          * @param appearanceDurationMs appearance animation duration, in milliseconds
81          * @param disappearanceDurationMs disappearance animation duration, in milliseconds
82          * @param interpolator animation interpolator
83          */
84         @JvmOverloads
85         @JvmStatic
86         fun createSimpleBorderAnimator(
87             @Px borderRadiusPx: Int,
88             @Px borderWidthPx: Int,
89             boundsBuilder: (Rect) -> Unit,
90             targetView: View,
91             @ColorInt borderColor: Int = DEFAULT_BORDER_COLOR,
92             appearanceDurationMs: Long = DEFAULT_APPEARANCE_ANIMATION_DURATION_MS,
93             disappearanceDurationMs: Long = DEFAULT_DISAPPEARANCE_ANIMATION_DURATION_MS,
94             interpolator: Interpolator = DEFAULT_INTERPOLATOR,
95         ): BorderAnimator {
96             return BorderAnimator(
97                 borderRadiusPx,
98                 borderColor,
99                 SimpleParams(borderWidthPx, boundsBuilder, targetView),
100                 appearanceDurationMs,
101                 disappearanceDurationMs,
102                 interpolator,
103             )
104         }
105 
106         /**
107          * Creates a BorderAnimator that scales the target and content views to draw the border
108          * within the target's bounds without obscuring the content.
109          *
110          * Use this method if the border would otherwise be clipped by the target view's bound.
111          *
112          * Note: using this method will set the scales and pivots of the container and content
113          * views, however will only reset the scales back to 1.
114          *
115          * @param borderRadiusPx the radius of the border's corners, in pixels
116          * @param borderWidthPx the width of the border, in pixels
117          * @param boundsBuilder callback to update the border bounds
118          * @param targetView the view that will be drawing the border
119          * @param contentView the view around which the border will be drawn. this view will be
120          *   scaled down reciprocally to keep its original size and location.
121          * @param borderColor the border's color
122          * @param appearanceDurationMs appearance animation duration, in milliseconds
123          * @param disappearanceDurationMs disappearance animation duration, in milliseconds
124          * @param interpolator animation interpolator
125          */
126         @JvmOverloads
127         @JvmStatic
128         fun createScalingBorderAnimator(
129             @Px borderRadiusPx: Int,
130             @Px borderWidthPx: Int,
131             boundsBuilder: (rect: Rect?) -> Unit,
132             targetView: View,
133             contentView: View,
134             @ColorInt borderColor: Int = DEFAULT_BORDER_COLOR,
135             appearanceDurationMs: Long = DEFAULT_APPEARANCE_ANIMATION_DURATION_MS,
136             disappearanceDurationMs: Long = DEFAULT_DISAPPEARANCE_ANIMATION_DURATION_MS,
137             interpolator: Interpolator = DEFAULT_INTERPOLATOR,
138         ): BorderAnimator {
139             return BorderAnimator(
140                 borderRadiusPx,
141                 borderColor,
142                 ScalingParams(borderWidthPx, boundsBuilder, targetView, contentView),
143                 appearanceDurationMs,
144                 disappearanceDurationMs,
145                 interpolator,
146             )
147         }
148     }
149 
150     private fun updateOutline() {
151         val interpolatedProgress = interpolator.getInterpolation(borderAnimationProgress.value)
152         borderAnimationParams.animationProgress = interpolatedProgress
153         borderPaint.alpha = (255 * interpolatedProgress).roundToInt()
154         borderPaint.strokeWidth = borderAnimationParams.borderWidth
155         borderAnimationParams.targetView.invalidate()
156     }
157 
158     /**
159      * Draws the border on the given canvas.
160      *
161      * Call this method in the target view's [View.draw] method after calling super.
162      */
163     fun drawBorder(canvas: Canvas) {
164         with(borderAnimationParams) {
165             val radius = borderRadiusPx + radiusAdjustment
166             canvas.drawRoundRect(
167                 /* left= */ borderBounds.left + alignmentAdjustment,
168                 /* top= */ borderBounds.top + alignmentAdjustment,
169                 /* right= */ borderBounds.right - alignmentAdjustment,
170                 /* bottom= */ borderBounds.bottom - alignmentAdjustment,
171                 /* rx= */ radius,
172                 /* ry= */ radius,
173                 /* paint= */ borderPaint
174             )
175         }
176     }
177 
178     /** Builds the border appearance/disappearance animation. */
179     fun buildAnimator(isAppearing: Boolean): Animator {
180         return borderAnimationProgress.animateToValue(if (isAppearing) 1f else 0f).apply {
181             duration = if (isAppearing) appearanceDurationMs else disappearanceDurationMs
182             doOnStart {
183                 runningBorderAnimation?.cancel()
184                 runningBorderAnimation = this
185                 borderAnimationParams.onShowBorder()
186             }
187             doOnEnd {
188                 runningBorderAnimation = null
189                 if (!isAppearing) {
190                     borderAnimationParams.onHideBorder()
191                 }
192             }
193         }
194     }
195 
196     /** Shows/hides the border, optionally with an animation. */
197     fun setBorderVisibility(visible: Boolean, animated: Boolean) {
198         if (animated) {
199             buildAnimator(visible).start()
200             return
201         }
202         runningBorderAnimation?.end()
203         if (visible) {
204             borderAnimationParams.onShowBorder()
205         }
206         borderAnimationProgress.updateValue(if (visible) 1f else 0f)
207         if (!visible) {
208             borderAnimationParams.onHideBorder()
209         }
210     }
211 
212     /** Params for handling different target view layout situations. */
213     private abstract class BorderAnimationParams(
214         @field:Px @param:Px val borderWidthPx: Int,
215         private val boundsBuilder: (rect: Rect) -> Unit,
216         val targetView: View,
217     ) {
218         val borderBounds = Rect()
219         var animationProgress = 0f
220         private var layoutChangeListener: OnLayoutChangeListener? = null
221 
222         abstract val alignmentAdjustmentInset: Int
223         abstract val radiusAdjustment: Float
224 
225         val borderWidth: Float
226             get() = borderWidthPx * animationProgress
227 
228         val alignmentAdjustment: Float
229             // Outset the border by half the width to create an outwards-growth animation
230             get() = -borderWidth / 2f + alignmentAdjustmentInset
231 
232         open fun onShowBorder() {
233             if (layoutChangeListener == null) {
234                 layoutChangeListener = OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
235                     onShowBorder()
236                     targetView.invalidate()
237                 }
238                 targetView.addOnLayoutChangeListener(layoutChangeListener)
239             }
240             boundsBuilder(borderBounds)
241         }
242 
243         open fun onHideBorder() {
244             if (layoutChangeListener != null) {
245                 targetView.removeOnLayoutChangeListener(layoutChangeListener)
246                 layoutChangeListener = null
247             }
248         }
249     }
250 
251     /** BorderAnimationParams that simply draws the border outside the bounds of the target view. */
252     private class SimpleParams(
253         @Px borderWidthPx: Int,
254         boundsBuilder: (Rect) -> Unit,
255         targetView: View,
256     ) : BorderAnimationParams(borderWidthPx, boundsBuilder, targetView) {
257         override val alignmentAdjustmentInset = 0
258         override val radiusAdjustment: Float
259             get() = -alignmentAdjustment
260     }
261 
262     /**
263      * BorderAnimationParams that scales the target and content views to draw the border within the
264      * target's bounds without obscuring the content.
265      */
266     private class ScalingParams(
267         @Px borderWidthPx: Int,
268         boundsBuilder: (rect: Rect?) -> Unit,
269         targetView: View,
270         private val contentView: View,
271     ) : BorderAnimationParams(borderWidthPx, boundsBuilder, targetView) {
272         // Inset the border since we are scaling the container up
273         override val alignmentAdjustmentInset = borderWidthPx
274         override val radiusAdjustment: Float
275             // Increase the radius since we are scaling the container up
276             get() = alignmentAdjustment
277 
278         override fun onShowBorder() {
279             super.onShowBorder()
280             val tvWidth = targetView.width.toFloat()
281             val tvHeight = targetView.height.toFloat()
282             // Scale up just enough to make room for the border. Fail fast and fix the scaling
283             // onLayout.
284             val newScaleX = if (tvWidth == 0f) 1f else 1f + 2 * borderWidthPx / tvWidth
285             val newScaleY = if (tvHeight == 0f) 1f else 1f + 2 * borderWidthPx / tvHeight
286             with(targetView) {
287                 pivotX = width / 2f
288                 pivotY = height / 2f
289                 scaleX = newScaleX
290                 scaleY = newScaleY
291             }
292             with(contentView) {
293                 pivotX = width / 2f
294                 pivotY = height / 2f
295                 scaleX = 1f / newScaleX
296                 scaleY = 1f / newScaleY
297             }
298         }
299 
300         override fun onHideBorder() {
301             super.onHideBorder()
302             with(targetView) {
303                 pivotX = width.toFloat()
304                 pivotY = height.toFloat()
305                 scaleX = 1f
306                 scaleY = 1f
307             }
308             with(contentView) {
309                 pivotX = width / 2f
310                 pivotY = height / 2f
311                 scaleX = 1f
312                 scaleY = 1f
313             }
314         }
315     }
316 }
317