• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 import kotlin.math.max
22 import kotlin.math.min
23 
24 /**
25  * Animates individual bubbles within the bubble bar while the bubble bar is expanded.
26  *
27  * This class should only be kept for the duration of the animation and a new instance should be
28  * created for each animation.
29  */
30 class BubbleAnimator(
31     private val iconSize: Float,
32     private val expandedBarIconSpacing: Float,
33     private val bubbleCount: Int,
34     private val onLeft: Boolean,
35 ) {
36 
37     companion object {
38         const val ANIMATION_DURATION_MS = 250L
39     }
40 
41     private var state: State = State.Idle
42     private lateinit var animator: ValueAnimator
43 
44     @JvmOverloads
45     fun animateNewBubble(
46         selectedBubbleIndex: Int,
47         newlySelectedBubbleIndex: Int? = null,
48         listener: Listener,
49     ) {
50         animator = createAnimator(listener)
51         state = State.AddingBubble(selectedBubbleIndex, newlySelectedBubbleIndex)
52         animator.start()
53     }
54 
55     fun animateRemovedBubble(
56         bubbleIndex: Int,
57         selectedBubbleIndex: Int,
58         removingLastBubble: Boolean,
59         removingLastRemainingBubble: Boolean,
60         listener: Listener,
61     ) {
62         animator = createAnimator(listener)
63         state =
64             State.RemovingBubble(
65                 bubbleIndex = bubbleIndex,
66                 selectedBubbleIndex = selectedBubbleIndex,
67                 removingLastBubble = removingLastBubble,
68                 removingLastRemainingBubble = removingLastRemainingBubble,
69             )
70         animator.start()
71     }
72 
73     fun animateNewAndRemoveOld(
74         selectedBubbleIndex: Int,
75         newlySelectedBubbleIndex: Int,
76         removedBubbleIndex: Int,
77         addedBubbleIndex: Int,
78         listener: Listener,
79     ) {
80         animator = createAnimator(listener)
81         state =
82             State.AddingAndRemoving(
83                 selectedBubbleIndex = selectedBubbleIndex,
84                 newlySelectedBubbleIndex = newlySelectedBubbleIndex,
85                 removedBubbleIndex = removedBubbleIndex,
86                 addedBubbleIndex = addedBubbleIndex,
87             )
88         animator.start()
89     }
90 
91     private fun createAnimator(listener: Listener): ValueAnimator {
92         val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(ANIMATION_DURATION_MS)
93         animator.addUpdateListener { animation ->
94             val animatedFraction = (animation as ValueAnimator).animatedFraction
95             listener.onAnimationUpdate(animatedFraction)
96         }
97         animator.addListener(
98             object : Animator.AnimatorListener {
99 
100                 override fun onAnimationCancel(animation: Animator) {
101                     listener.onAnimationCancel()
102                 }
103 
104                 override fun onAnimationEnd(animation: Animator) {
105                     state = State.Idle
106                     listener.onAnimationEnd()
107                 }
108 
109                 override fun onAnimationRepeat(animation: Animator) {}
110 
111                 override fun onAnimationStart(animation: Animator) {}
112             }
113         )
114         return animator
115     }
116 
117     /**
118      * The translation X of the bubble at index [bubbleIndex] when the bubble bar is expanded
119      * according to the progress of this animation.
120      *
121      * Callers should verify that the animation is running before calling this.
122      *
123      * @see isRunning
124      */
125     fun getBubbleTranslationX(bubbleIndex: Int): Float {
126         return when (val state = state) {
127             State.Idle -> 0f
128             is State.AddingBubble ->
129                 getBubbleTranslationXWhileScalingBubble(
130                     bubbleIndex = bubbleIndex,
131                     scalingBubbleIndex = 0,
132                     bubbleScale = animator.animatedFraction,
133                 )
134 
135             is State.RemovingBubble ->
136                 getBubbleTranslationXWhileScalingBubble(
137                     bubbleIndex = bubbleIndex,
138                     scalingBubbleIndex = state.bubbleIndex,
139                     bubbleScale = 1 - animator.animatedFraction,
140                 )
141 
142             is State.AddingAndRemoving ->
143                 getBubbleTranslationXWhileAddingBubbleAtLimit(
144                     bubbleIndex = bubbleIndex,
145                     removedBubbleIndex = state.removedBubbleIndex,
146                     addedBubbleIndex = state.addedBubbleIndex,
147                     addedBubbleScale = animator.animatedFraction,
148                     removedBubbleScale = 1 - animator.animatedFraction,
149                 )
150         }
151     }
152 
153     /**
154      * The expanded width of the bubble bar according to the progress of the animation.
155      *
156      * Callers should verify that the animation is running before calling this.
157      *
158      * @see isRunning
159      */
160     fun getExpandedWidth(): Float {
161         val bubbleScale =
162             when (state) {
163                 State.Idle -> 0f
164                 is State.AddingBubble -> animator.animatedFraction
165                 is State.RemovingBubble -> 1 - animator.animatedFraction
166                 is State.AddingAndRemoving -> {
167                     // since we're adding a bubble and removing another bubble, their sizes together
168                     // equal to a single bubble. the width is the same as having bubbleCount - 1
169                     // bubbles at full scale.
170                     val totalSpace = (bubbleCount - 2) * expandedBarIconSpacing
171                     val totalIconSize = (bubbleCount - 1) * iconSize
172                     return totalIconSize + totalSpace
173                 }
174             }
175         // When this animator is running the bubble bar is expanded so it's safe to assume that we
176         // have at least 2 bubbles, but should update the logic to support optional overflow.
177         // If we're removing the last bubble, the entire bar should animate and we shouldn't get
178         // here.
179         val totalSpace = (bubbleCount - 2 + bubbleScale) * expandedBarIconSpacing
180         val totalIconSize = (bubbleCount - 1 + bubbleScale) * iconSize
181         return totalIconSize + totalSpace
182     }
183 
184     /**
185      * Returns the arrow position according to the progress of the animation and, if the selected
186      * bubble is being removed, accounting to the newly selected bubble.
187      *
188      * Callers should verify that the animation is running before calling this.
189      *
190      * @see isRunning
191      */
192     fun getArrowPosition(): Float {
193         return when (val state = state) {
194             State.Idle -> 0f
195             is State.AddingBubble -> getArrowPositionWhenAddingBubble(state)
196             is State.RemovingBubble -> getArrowPositionWhenRemovingBubble(state)
197             is State.AddingAndRemoving -> getArrowPositionWhenAddingAndRemovingBubble(state)
198         }
199     }
200 
201     private fun getArrowPositionWhenAddingBubble(state: State.AddingBubble): Float {
202         val scale = animator.animatedFraction
203         var tx =
204             getBubbleTranslationXWhileScalingBubble(
205                 bubbleIndex = state.selectedBubbleIndex,
206                 scalingBubbleIndex = 0,
207                 bubbleScale = scale,
208             ) + iconSize / 2f
209         if (state.newlySelectedBubbleIndex != null) {
210             val selectedBubbleScale = if (state.newlySelectedBubbleIndex == 0) scale else 1f
211             val finalTx =
212                 getBubbleTranslationXWhileScalingBubble(
213                     bubbleIndex = state.newlySelectedBubbleIndex,
214                     scalingBubbleIndex = 0,
215                     bubbleScale = scale,
216                 ) + iconSize * selectedBubbleScale / 2f
217             tx += (finalTx - tx) * animator.animatedFraction
218         }
219         return tx
220     }
221 
222     private fun getArrowPositionWhenRemovingBubble(state: State.RemovingBubble): Float =
223         if (state.selectedBubbleIndex != state.bubbleIndex || state.removingLastRemainingBubble) {
224             // if we're not removing the selected bubble or if we're removing the last remaining
225             // bubble, the selected bubble doesn't change so just return the translation X of the
226             // selected bubble and add half icon
227             val tx =
228                 getBubbleTranslationXWhileScalingBubble(
229                     bubbleIndex = state.selectedBubbleIndex,
230                     scalingBubbleIndex = state.bubbleIndex,
231                     bubbleScale = 1 - animator.animatedFraction,
232                 )
233             tx + iconSize / 2f
234         } else {
235             // we're removing the selected bubble so the arrow needs to point to a different bubble.
236             // if we're removing the last bubble the newly selected bubble will be the second to
237             // last. otherwise, it'll be the next bubble (closer to the overflow)
238             val iconAndSpacing = iconSize + expandedBarIconSpacing
239             if (state.removingLastBubble) {
240                 if (onLeft) {
241                     // the newly selected bubble is the bubble to the right. at the end of the
242                     // animation all the bubbles will have shifted left, so the arrow stays at the
243                     // same distance from the left edge of bar
244                     (bubbleCount - state.bubbleIndex - 1) * iconAndSpacing + iconSize / 2f
245                 } else {
246                     // the newly selected bubble is the bubble to the left. at the end of the
247                     // animation all the bubbles will have shifted right, and the arrow would
248                     // eventually be closer to the left edge of the bar by iconAndSpacing
249                     val initialTx = state.bubbleIndex * iconAndSpacing + iconSize / 2f
250                     initialTx - animator.animatedFraction * iconAndSpacing
251                 }
252             } else {
253                 if (onLeft) {
254                     // the newly selected bubble is to the left, and bubbles are shifting left, so
255                     // move the arrow closer to the left edge of the bar by iconAndSpacing
256                     val initialTx =
257                         (bubbleCount - state.bubbleIndex - 1) * iconAndSpacing + iconSize / 2f
258                     initialTx - animator.animatedFraction * iconAndSpacing
259                 } else {
260                     // the newly selected bubble is to the right, and bubbles are shifting right, so
261                     // the arrow stays at the same distance from the left edge of the bar
262                     state.bubbleIndex * iconAndSpacing + iconSize / 2f
263                 }
264             }
265         }
266 
267     private fun getArrowPositionWhenAddingAndRemovingBubble(state: State.AddingAndRemoving): Float {
268         // The bubble bar keeps constant width while adding and removing bubble. So we just need to
269         // find selected bubble arrow position on the animation start and newly selected bubble
270         // arrow position on the animation end interpolating the arrow between these positions
271         // during the animation.
272         // The indexes in the state are provided for the bubble bar containing all bubbles. So for
273         // certain circumstances indexes should be adjusted.
274         // When animation is started added bubble has zero scale as well as removed bubble when the
275         // animation is ended, so for both cases we should compute translation as it is one less
276         // bubble.
277         val bubbleCountOnEnd = bubbleCount - 1
278         var selectedIndex = state.selectedBubbleIndex
279         // We only need to adjust the selected index if added bubble was added before the selected.
280         if (selectedIndex > state.addedBubbleIndex) {
281             // If the selectedIndex is higher index than the added bubble index, we need to reduce
282             // selectedIndex by one because the added bubble  has zero scale when animation is
283             // started.
284             selectedIndex--
285         }
286         var newlySelectedIndex = state.newlySelectedBubbleIndex
287         // We only need to adjust newlySelectedIndex if removed bubble was removed before the newly
288         // selected bubble.
289         if (newlySelectedIndex > state.removedBubbleIndex) {
290             // If the newlySelectedIndex is higher index than the removed bubble index, we need to
291             // reduce newlySelectedIndex by one because the removed bubble has zero scale when
292             // animation is ended.
293             newlySelectedIndex--
294         }
295         val iconAndSpacing: Float = iconSize + expandedBarIconSpacing
296         val startTx = getBubblesToTheLeft(selectedIndex, bubbleCountOnEnd) * iconAndSpacing
297         val endTx = getBubblesToTheLeft(newlySelectedIndex, bubbleCountOnEnd) * iconAndSpacing
298         val tx = startTx + (endTx - startTx) * animator.animatedFraction
299         return tx + iconSize / 2f
300     }
301 
302     private fun getBubblesToTheLeft(bubbleIndex: Int, bubbleCount: Int = this.bubbleCount): Int =
303         // when bar is on left the index - 0 corresponds to the right - most bubble and when the
304         // bubble bar is on the right - 0 corresponds to the left - most bubble.
305         if (onLeft) bubbleCount - bubbleIndex - 1 else bubbleIndex
306 
307     /**
308      * Returns the translation X for the bubble at index {@code bubbleIndex} when the bubble bar is
309      * expanded and a bubble is animating in or out.
310      *
311      * @param bubbleIndex the index of the bubble for which the translation is requested
312      * @param scalingBubbleIndex the index of the bubble that is animating
313      * @param bubbleScale the current scale of the animating bubble
314      */
315     private fun getBubbleTranslationXWhileScalingBubble(
316         bubbleIndex: Int,
317         scalingBubbleIndex: Int,
318         bubbleScale: Float,
319     ): Float {
320         val iconAndSpacing = iconSize + expandedBarIconSpacing
321         // the bubble is scaling from the center, so we need to adjust its translation so
322         // that the distance to the adjacent bubble scales at the same rate.
323         val pivotAdjustment = -(1 - bubbleScale) * iconSize / 2f
324 
325         return if (onLeft) {
326             when {
327                 bubbleIndex < scalingBubbleIndex ->
328                     // the bar is on the left and the current bubble is to the right of the scaling
329                     // bubble so account for its scale
330                     (bubbleCount - bubbleIndex - 2 + bubbleScale) * iconAndSpacing
331 
332                 bubbleIndex == scalingBubbleIndex -> {
333                     // the bar is on the left and this is the scaling bubble
334                     val totalIconSize = (bubbleCount - bubbleIndex - 1) * iconSize
335                     // don't count the spacing between the scaling bubble and the bubble on the left
336                     // because we need to scale that space
337                     val totalSpacing = (bubbleCount - bubbleIndex - 2) * expandedBarIconSpacing
338                     val scaledSpace = bubbleScale * expandedBarIconSpacing
339                     totalIconSize + totalSpacing + scaledSpace + pivotAdjustment
340                 }
341 
342                 else ->
343                     // the bar is on the left and the scaling bubble is on the right. the current
344                     // bubble is unaffected by the scaling bubble
345                     (bubbleCount - bubbleIndex - 1) * iconAndSpacing
346             }
347         } else {
348             when {
349                 bubbleIndex < scalingBubbleIndex ->
350                     // the bar is on the right and the scaling bubble is on the right. the current
351                     // bubble is unaffected by the scaling bubble
352                     iconAndSpacing * bubbleIndex
353 
354                 bubbleIndex == scalingBubbleIndex ->
355                     // the bar is on the right, and this is the animating bubble. it only needs to
356                     // be adjusted for the scaling pivot.
357                     iconAndSpacing * bubbleIndex + pivotAdjustment
358 
359                 else ->
360                     // the bar is on the right and the scaling bubble is on the left so account for
361                     // its scale
362                     iconAndSpacing * (bubbleIndex - 1 + bubbleScale)
363             }
364         }
365     }
366 
367     private fun getBubbleTranslationXWhileAddingBubbleAtLimit(
368         bubbleIndex: Int,
369         removedBubbleIndex: Int,
370         addedBubbleIndex: Int,
371         addedBubbleScale: Float,
372         removedBubbleScale: Float,
373     ): Float {
374         val iconAndSpacing = iconSize + expandedBarIconSpacing
375         // the bubbles are scaling from the center, so we need to adjust their translation so
376         // that the distance to the adjacent bubble scales at the same rate.
377         val addedBubblePivotAdjustment = (addedBubbleScale - 1) * iconSize / 2f
378         val removedBubblePivotAdjustment = (removedBubbleScale - 1) * iconSize / 2f
379 
380         val minAddedRemovedIndex = min(addedBubbleIndex, removedBubbleIndex)
381         val maxAddedRemovedIndex = max(addedBubbleIndex, removedBubbleIndex)
382         val isBetweenAddedAndRemoved =
383             bubbleIndex in (minAddedRemovedIndex + 1)..<maxAddedRemovedIndex
384         val isRemovedBubbleToLeftOfAddedBubble = onLeft == addedBubbleIndex < removedBubbleIndex
385         val bubblesToLeft = getBubblesToTheLeft(bubbleIndex)
386         return when {
387             isBetweenAddedAndRemoved -> {
388                 if (isRemovedBubbleToLeftOfAddedBubble) {
389                     // the removed bubble is to the left so account for it
390                     (bubblesToLeft - 1 + removedBubbleScale) * iconAndSpacing
391                 } else {
392                     // the added bubble is to the left so account for it
393                     (bubblesToLeft - 1 + addedBubbleScale) * iconAndSpacing
394                 }
395             }
396 
397             bubbleIndex == addedBubbleIndex -> {
398                 if (isRemovedBubbleToLeftOfAddedBubble) {
399                     // the removed bubble is to the left so account for it
400                     (bubblesToLeft - 1 + removedBubbleScale) * iconAndSpacing
401                 } else {
402                     // it's the left-most scaling bubble, all bubbles on the left are at full scale
403                     bubblesToLeft * iconAndSpacing
404                 } + addedBubblePivotAdjustment
405             }
406 
407             bubbleIndex == removedBubbleIndex -> {
408                 if (isRemovedBubbleToLeftOfAddedBubble) {
409                     // All the bubbles to the left are at full scale, but we need to scale the
410                     // spacing between the removed bubble and the bubble next to it
411                     val totalIconSize = bubblesToLeft * iconSize
412                     val totalSpacing =
413                         (bubblesToLeft - 1 + removedBubbleScale) * expandedBarIconSpacing
414                     totalIconSize + totalSpacing
415                 } else {
416                     // The added bubble is to the left, so account for it
417                     (bubblesToLeft - 1 + addedBubbleScale) * iconAndSpacing
418                 } + removedBubblePivotAdjustment
419             }
420 
421             else -> {
422                 // if bubble index is on the right side of the animated bubbles, we need to deduct
423                 // one, since both the added and the removed bubbles takes a single place
424                 val onTheRightOfAnimatedBubbles =
425                     if (onLeft) {
426                         bubbleIndex < minAddedRemovedIndex
427                     } else {
428                         bubbleIndex > maxAddedRemovedIndex
429                     }
430                 (bubblesToLeft - if (onTheRightOfAnimatedBubbles) 1 else 0) * iconAndSpacing
431             }
432         }
433     }
434 
435     val isRunning: Boolean
436         get() = state != State.Idle
437 
438     /** The state of the animation. */
439     sealed interface State {
440 
441         /** The animation is not running. */
442         data object Idle : State
443 
444         /** A new bubble is being added to the bubble bar. */
445         data class AddingBubble(
446             /** The index of the selected bubble. */
447             val selectedBubbleIndex: Int,
448             /** The index of the newly selected bubble. */
449             val newlySelectedBubbleIndex: Int?,
450         ) : State
451 
452         /** A bubble is being removed from the bubble bar. */
453         data class RemovingBubble(
454             /** The index of the bubble being removed. */
455             val bubbleIndex: Int,
456             /** The index of the selected bubble. */
457             val selectedBubbleIndex: Int,
458             /** Whether the bubble being removed is also the last bubble. */
459             val removingLastBubble: Boolean,
460             /** Whether we're removing the last remaining bubble. */
461             val removingLastRemainingBubble: Boolean,
462         ) : State
463 
464         /** A new bubble is being added and an old bubble is being removed from the bubble bar. */
465         data class AddingAndRemoving(
466             /** The index of the selected bubble. */
467             val selectedBubbleIndex: Int,
468             /** The index of the newly selected bubble. */
469             val newlySelectedBubbleIndex: Int,
470             /** The index of the bubble being removed. */
471             val removedBubbleIndex: Int,
472             /** The index of the added bubble. */
473             val addedBubbleIndex: Int,
474         ) : State
475     }
476 
477     /** Callbacks for the animation. */
478     interface Listener {
479 
480         /**
481          * Notifies the listener of an animation update event, where `animatedFraction` represents
482          * the progress of the animation starting from 0 and ending at 1.
483          */
484         fun onAnimationUpdate(animatedFraction: Float)
485 
486         /** Notifies the listener that the animation was canceled. */
487         fun onAnimationCancel()
488 
489         /** Notifies that listener that the animation ended. */
490         fun onAnimationEnd()
491     }
492 }
493