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