1 /*
<lambda>null2  * Copyright (C) 2024 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.taskbar.bubbles.animation
18 
19 import androidx.core.animation.Animator
20 import androidx.core.animation.ValueAnimator
21 
22 /**
23  * Animates individual bubbles within the bubble bar while the bubble bar is expanded.
24  *
25  * This class should only be kept for the duration of the animation and a new instance should be
26  * created for each animation.
27  */
28 class BubbleAnimator(
29     private val iconSize: Float,
30     private val expandedBarIconSpacing: Float,
31     private val bubbleCount: Int,
32     private val onLeft: Boolean,
33 ) {
34 
35     companion object {
36         const val ANIMATION_DURATION_MS = 250L
37     }
38 
39     private var state: State = State.Idle
40     private lateinit var animator: ValueAnimator
41 
42     fun animateNewBubble(selectedBubbleIndex: Int, listener: Listener) {
43         animator = createAnimator(listener)
44         state = State.AddingBubble(selectedBubbleIndex)
45         animator.start()
46     }
47 
48     fun animateRemovedBubble(
49         bubbleIndex: Int,
50         selectedBubbleIndex: Int,
51         removingLastBubble: Boolean,
52         removingLastRemainingBubble: Boolean,
53         listener: Listener,
54     ) {
55         animator = createAnimator(listener)
56         state =
57             State.RemovingBubble(
58                 bubbleIndex = bubbleIndex,
59                 selectedBubbleIndex = selectedBubbleIndex,
60                 removingLastBubble = removingLastBubble,
61                 removingLastRemainingBubble = removingLastRemainingBubble,
62             )
63         animator.start()
64     }
65 
66     fun animateNewAndRemoveOld(
67         selectedBubbleIndex: Int,
68         removedBubbleIndex: Int,
69         listener: Listener,
70     ) {
71         animator = createAnimator(listener)
72         state =
73             State.AddingAndRemoving(
74                 selectedBubbleIndex = selectedBubbleIndex,
75                 removedBubbleIndex = removedBubbleIndex,
76             )
77         animator.start()
78     }
79 
80     private fun createAnimator(listener: Listener): ValueAnimator {
81         val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(ANIMATION_DURATION_MS)
82         animator.addUpdateListener { animation ->
83             val animatedFraction = (animation as ValueAnimator).animatedFraction
84             listener.onAnimationUpdate(animatedFraction)
85         }
86         animator.addListener(
87             object : Animator.AnimatorListener {
88 
89                 override fun onAnimationCancel(animation: Animator) {
90                     listener.onAnimationCancel()
91                 }
92 
93                 override fun onAnimationEnd(animation: Animator) {
94                     state = State.Idle
95                     listener.onAnimationEnd()
96                 }
97 
98                 override fun onAnimationRepeat(animation: Animator) {}
99 
100                 override fun onAnimationStart(animation: Animator) {}
101             }
102         )
103         return animator
104     }
105 
106     /**
107      * The translation X of the bubble at index [bubbleIndex] when the bubble bar is expanded
108      * according to the progress of this animation.
109      *
110      * Callers should verify that the animation is running before calling this.
111      *
112      * @see isRunning
113      */
114     fun getBubbleTranslationX(bubbleIndex: Int): Float {
115         return when (val state = state) {
116             State.Idle -> 0f
117             is State.AddingBubble ->
118                 getBubbleTranslationXWhileScalingBubble(
119                     bubbleIndex = bubbleIndex,
120                     scalingBubbleIndex = 0,
121                     bubbleScale = animator.animatedFraction,
122                 )
123 
124             is State.RemovingBubble ->
125                 getBubbleTranslationXWhileScalingBubble(
126                     bubbleIndex = bubbleIndex,
127                     scalingBubbleIndex = state.bubbleIndex,
128                     bubbleScale = 1 - animator.animatedFraction,
129                 )
130 
131             is State.AddingAndRemoving ->
132                 getBubbleTranslationXWhileAddingBubbleAtLimit(
133                     bubbleIndex = bubbleIndex,
134                     removedBubbleIndex = state.removedBubbleIndex,
135                     addedBubbleScale = animator.animatedFraction,
136                     removedBubbleScale = 1 - animator.animatedFraction,
137                 )
138         }
139     }
140 
141     /**
142      * The expanded width of the bubble bar according to the progress of the animation.
143      *
144      * Callers should verify that the animation is running before calling this.
145      *
146      * @see isRunning
147      */
148     fun getExpandedWidth(): Float {
149         val bubbleScale =
150             when (state) {
151                 State.Idle -> 0f
152                 is State.AddingBubble -> animator.animatedFraction
153                 is State.RemovingBubble -> 1 - animator.animatedFraction
154                 is State.AddingAndRemoving -> {
155                     // since we're adding a bubble and removing another bubble, their sizes together
156                     // equal to a single bubble. the width is the same as having bubbleCount - 1
157                     // bubbles at full scale.
158                     val totalSpace = (bubbleCount - 2) * expandedBarIconSpacing
159                     val totalIconSize = (bubbleCount - 1) * iconSize
160                     return totalIconSize + totalSpace
161                 }
162             }
163         // When this animator is running the bubble bar is expanded so it's safe to assume that we
164         // have at least 2 bubbles, but should update the logic to support optional overflow.
165         // If we're removing the last bubble, the entire bar should animate and we shouldn't get
166         // here.
167         val totalSpace = (bubbleCount - 2 + bubbleScale) * expandedBarIconSpacing
168         val totalIconSize = (bubbleCount - 1 + bubbleScale) * iconSize
169         return totalIconSize + totalSpace
170     }
171 
172     /**
173      * Returns the arrow position according to the progress of the animation and, if the selected
174      * bubble is being removed, accounting to the newly selected bubble.
175      *
176      * Callers should verify that the animation is running before calling this.
177      *
178      * @see isRunning
179      */
180     fun getArrowPosition(): Float {
181         return when (val state = state) {
182             State.Idle -> 0f
183             is State.AddingBubble -> {
184                 val tx =
185                     getBubbleTranslationXWhileScalingBubble(
186                         bubbleIndex = state.selectedBubbleIndex,
187                         scalingBubbleIndex = 0,
188                         bubbleScale = animator.animatedFraction,
189                     )
190                 tx + iconSize / 2f
191             }
192 
193             is State.RemovingBubble -> getArrowPositionWhenRemovingBubble(state)
194             is State.AddingAndRemoving -> {
195                 // we never remove the selected bubble, so the arrow stays pointing to its center
196                 val tx =
197                     getBubbleTranslationXWhileAddingBubbleAtLimit(
198                         bubbleIndex = state.selectedBubbleIndex,
199                         removedBubbleIndex = state.removedBubbleIndex,
200                         addedBubbleScale = animator.animatedFraction,
201                         removedBubbleScale = 1 - animator.animatedFraction,
202                     )
203                 tx + iconSize / 2f
204             }
205         }
206     }
207 
208     private fun getArrowPositionWhenRemovingBubble(state: State.RemovingBubble): Float =
209         if (state.selectedBubbleIndex != state.bubbleIndex || state.removingLastRemainingBubble) {
210             // if we're not removing the selected bubble or if we're removing the last remaining
211             // bubble, the selected bubble doesn't change so just return the translation X of the
212             // selected bubble and add half icon
213             val tx =
214                 getBubbleTranslationXWhileScalingBubble(
215                     bubbleIndex = state.selectedBubbleIndex,
216                     scalingBubbleIndex = state.bubbleIndex,
217                     bubbleScale = 1 - animator.animatedFraction,
218                 )
219             tx + iconSize / 2f
220         } else {
221             // we're removing the selected bubble so the arrow needs to point to a different bubble.
222             // if we're removing the last bubble the newly selected bubble will be the second to
223             // last. otherwise, it'll be the next bubble (closer to the overflow)
224             val iconAndSpacing = iconSize + expandedBarIconSpacing
225             if (state.removingLastBubble) {
226                 if (onLeft) {
227                     // the newly selected bubble is the bubble to the right. at the end of the
228                     // animation all the bubbles will have shifted left, so the arrow stays at the
229                     // same distance from the left edge of bar
230                     (bubbleCount - state.bubbleIndex - 1) * iconAndSpacing + iconSize / 2f
231                 } else {
232                     // the newly selected bubble is the bubble to the left. at the end of the
233                     // animation all the bubbles will have shifted right, and the arrow would
234                     // eventually be closer to the left edge of the bar by iconAndSpacing
235                     val initialTx = state.bubbleIndex * iconAndSpacing + iconSize / 2f
236                     initialTx - animator.animatedFraction * iconAndSpacing
237                 }
238             } else {
239                 if (onLeft) {
240                     // the newly selected bubble is to the left, and bubbles are shifting left, so
241                     // move the arrow closer to the left edge of the bar by iconAndSpacing
242                     val initialTx =
243                         (bubbleCount - state.bubbleIndex - 1) * iconAndSpacing + iconSize / 2f
244                     initialTx - animator.animatedFraction * iconAndSpacing
245                 } else {
246                     // the newly selected bubble is to the right, and bubbles are shifting right, so
247                     // the arrow stays at the same distance from the left edge of the bar
248                     state.bubbleIndex * iconAndSpacing + iconSize / 2f
249                 }
250             }
251         }
252 
253     /**
254      * Returns the translation X for the bubble at index {@code bubbleIndex} when the bubble bar is
255      * expanded and a bubble is animating in or out.
256      *
257      * @param bubbleIndex the index of the bubble for which the translation is requested
258      * @param scalingBubbleIndex the index of the bubble that is animating
259      * @param bubbleScale the current scale of the animating bubble
260      */
261     private fun getBubbleTranslationXWhileScalingBubble(
262         bubbleIndex: Int,
263         scalingBubbleIndex: Int,
264         bubbleScale: Float,
265     ): Float {
266         val iconAndSpacing = iconSize + expandedBarIconSpacing
267         // the bubble is scaling from the center, so we need to adjust its translation so
268         // that the distance to the adjacent bubble scales at the same rate.
269         val pivotAdjustment = -(1 - bubbleScale) * iconSize / 2f
270 
271         return if (onLeft) {
272             when {
273                 bubbleIndex < scalingBubbleIndex ->
274                     // the bar is on the left and the current bubble is to the right of the scaling
275                     // bubble so account for its scale
276                     (bubbleCount - bubbleIndex - 2 + bubbleScale) * iconAndSpacing
277                 bubbleIndex == scalingBubbleIndex -> {
278                     // the bar is on the left and this is the scaling bubble
279                     val totalIconSize = (bubbleCount - bubbleIndex - 1) * iconSize
280                     // don't count the spacing between the scaling bubble and the bubble on the left
281                     // because we need to scale that space
282                     val totalSpacing = (bubbleCount - bubbleIndex - 2) * expandedBarIconSpacing
283                     val scaledSpace = bubbleScale * expandedBarIconSpacing
284                     totalIconSize + totalSpacing + scaledSpace + pivotAdjustment
285                 }
286                 else ->
287                     // the bar is on the left and the scaling bubble is on the right. the current
288                     // bubble is unaffected by the scaling bubble
289                     (bubbleCount - bubbleIndex - 1) * iconAndSpacing
290             }
291         } else {
292             when {
293                 bubbleIndex < scalingBubbleIndex ->
294                     // the bar is on the right and the scaling bubble is on the right. the current
295                     // bubble is unaffected by the scaling bubble
296                     iconAndSpacing * bubbleIndex
297                 bubbleIndex == scalingBubbleIndex ->
298                     // the bar is on the right, and this is the animating bubble. it only needs to
299                     // be adjusted for the scaling pivot.
300                     iconAndSpacing * bubbleIndex + pivotAdjustment
301                 else ->
302                     // the bar is on the right and the scaling bubble is on the left so account for
303                     // its scale
304                     iconAndSpacing * (bubbleIndex - 1 + bubbleScale)
305             }
306         }
307     }
308 
309     private fun getBubbleTranslationXWhileAddingBubbleAtLimit(
310         bubbleIndex: Int,
311         removedBubbleIndex: Int,
312         addedBubbleScale: Float,
313         removedBubbleScale: Float,
314     ): Float {
315         val iconAndSpacing = iconSize + expandedBarIconSpacing
316         // the bubbles are scaling from the center, so we need to adjust their translation so
317         // that the distance to the adjacent bubble scales at the same rate.
318         val addedBubblePivotAdjustment = -(1 - addedBubbleScale) * iconSize / 2f
319         val removedBubblePivotAdjustment = -(1 - removedBubbleScale) * iconSize / 2f
320 
321         return if (onLeft) {
322             // this is how many bubbles there are to the left of the current bubble.
323             // when the bubble bar is on the right the added bubble is the right-most bubble so it
324             // doesn't affect the translation of any other bubble.
325             // when the removed bubble is to the left of the current bubble, we need to subtract it
326             // from bubblesToLeft and use removedBubbleScale instead when calculating the
327             // translation.
328             val bubblesToLeft = bubbleCount - bubbleIndex - 1
329             when {
330                 bubbleIndex == 0 ->
331                     // this is the added bubble and it's the right-most bubble. account for all the
332                     // other bubbles -- including the removed bubble -- and adjust for the added
333                     // bubble pivot.
334                     (bubblesToLeft - 1 + removedBubbleScale) * iconAndSpacing +
335                         addedBubblePivotAdjustment
336                 bubbleIndex < removedBubbleIndex ->
337                     // the removed bubble is to the left so account for it
338                     (bubblesToLeft - 1 + removedBubbleScale) * iconAndSpacing
339                 bubbleIndex == removedBubbleIndex -> {
340                     // this is the removed bubble. all the bubbles to the left are at full scale
341                     // but we need to scale the spacing between the removed bubble and the bubble to
342                     // its left because the removed bubble disappears towards the left side
343                     val totalIconSize = bubblesToLeft * iconSize
344                     val totalSpacing =
345                         (bubblesToLeft - 1 + removedBubbleScale) * expandedBarIconSpacing
346                     totalIconSize + totalSpacing + removedBubblePivotAdjustment
347                 }
348                 else ->
349                     // both added and removed bubbles are to the right so they don't affect the tx
350                     bubblesToLeft * iconAndSpacing
351             }
352         } else {
353             when {
354                 bubbleIndex == 0 -> addedBubblePivotAdjustment // we always add bubbles at index 0
355                 bubbleIndex < removedBubbleIndex ->
356                     // the bar is on the right and the removed bubble is on the right. the current
357                     // bubble is unaffected by the removed bubble. only need to factor in the added
358                     // bubble's scale.
359                     iconAndSpacing * (bubbleIndex - 1 + addedBubbleScale)
360                 bubbleIndex == removedBubbleIndex ->
361                     // the bar is on the right, and this is the animating bubble.
362                     iconAndSpacing * (bubbleIndex - 1 + addedBubbleScale) +
363                         removedBubblePivotAdjustment
364                 else ->
365                     // both the added and the removed bubbles are to the left of the current bubble
366                     iconAndSpacing * (bubbleIndex - 2 + addedBubbleScale + removedBubbleScale)
367             }
368         }
369     }
370 
371     val isRunning: Boolean
372         get() = state != State.Idle
373 
374     /** The state of the animation. */
375     sealed interface State {
376 
377         /** The animation is not running. */
378         data object Idle : State
379 
380         /** A new bubble is being added to the bubble bar. */
381         data class AddingBubble(val selectedBubbleIndex: Int) : State
382 
383         /** A bubble is being removed from the bubble bar. */
384         data class RemovingBubble(
385             /** The index of the bubble being removed. */
386             val bubbleIndex: Int,
387             /** The index of the selected bubble. */
388             val selectedBubbleIndex: Int,
389             /** Whether the bubble being removed is also the last bubble. */
390             val removingLastBubble: Boolean,
391             /** Whether we're removing the last remaining bubble. */
392             val removingLastRemainingBubble: Boolean,
393         ) : State
394 
395         /** A new bubble is being added and an old bubble is being removed from the bubble bar. */
396         data class AddingAndRemoving(val selectedBubbleIndex: Int, val removedBubbleIndex: Int) :
397             State
398     }
399 
400     /** Callbacks for the animation. */
401     interface Listener {
402 
403         /**
404          * Notifies the listener of an animation update event, where `animatedFraction` represents
405          * the progress of the animation starting from 0 and ending at 1.
406          */
407         fun onAnimationUpdate(animatedFraction: Float)
408 
409         /** Notifies the listener that the animation was canceled. */
410         fun onAnimationCancel()
411 
412         /** Notifies that listener that the animation ended. */
413         fun onAnimationEnd()
414     }
415 }
416