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