1 /* 2 * Copyright (C) 2020 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.systemui.util.animation 18 19 import android.animation.ValueAnimator 20 import android.graphics.PointF 21 import android.util.MathUtils 22 import com.android.app.animation.Interpolators 23 24 /** 25 * The fraction after which we start fading in when going from a gone widget to a visible one 26 */ 27 private const val GONE_FADE_FRACTION = 0.8f 28 /** 29 * The fraction after which we start fading in going from a gone widget to a visible one in guts 30 * animation. 31 */ 32 private const val GONE_FADE_GUTS_FRACTION = 0.286f 33 /** 34 * The fraction before which we fade out when going from a visible widget to a gone one in guts 35 * animation. 36 */ 37 private const val VISIBLE_FADE_GUTS_FRACTION = 0.355f 38 39 /** 40 * The amont we're scaling appearing views 41 */ 42 private const val GONE_SCALE_AMOUNT = 0.8f 43 44 /** 45 * A controller for a [TransitionLayout] which handles state transitions and keeps the transition 46 * layout up to date with the desired state. 47 */ 48 open class TransitionLayoutController { 49 50 /** 51 * The layout that this controller controls 52 */ 53 private var transitionLayout: TransitionLayout? = null 54 private var currentState = TransitionViewState() 55 private var animationStartState: TransitionViewState? = null 56 private var state = TransitionViewState() 57 private var animator: ValueAnimator = ValueAnimator.ofFloat(0.0f, 1.0f) 58 private var isGutsAnimation = false 59 private var currentHeight: Int = 0 60 private var currentWidth: Int = 0 61 var sizeChangedListener: ((Int, Int) -> Unit)? = null 62 63 init { <lambda>null64 animator.apply { 65 addUpdateListener { 66 updateStateFromAnimation() 67 } 68 interpolator = Interpolators.FAST_OUT_SLOW_IN 69 } 70 } 71 updateStateFromAnimationnull72 private fun updateStateFromAnimation() { 73 if (animationStartState == null || !animator.isRunning) { 74 return 75 } 76 currentState = getInterpolatedState( 77 startState = animationStartState!!, 78 endState = state, 79 progress = animator.animatedFraction, 80 reusedState = currentState) 81 applyStateToLayout(currentState) 82 } 83 applyStateToLayoutnull84 private fun applyStateToLayout(state: TransitionViewState) { 85 transitionLayout?.setState(state) 86 if (currentHeight != state.height || currentWidth != state.width) { 87 currentHeight = state.height 88 currentWidth = state.width 89 sizeChangedListener?.invoke(currentWidth, currentHeight) 90 } 91 } 92 93 /** 94 * Obtain a state that is gone, based on parameters given. 95 * 96 * @param viewState the viewState to make gone 97 * @param disappearParameters parameters that determine how the view should disappear 98 * @param goneProgress how much is the view gone? 0 for not gone at all and 1 for fully 99 * disappeared 100 * @param reusedState optional parameter for state to be reused to avoid allocations 101 */ getGoneStatenull102 fun getGoneState( 103 viewState: TransitionViewState, 104 disappearParameters: DisappearParameters, 105 goneProgress: Float, 106 reusedState: TransitionViewState? = null 107 ): TransitionViewState { 108 var remappedProgress = MathUtils.map( 109 disappearParameters.disappearStart, 110 disappearParameters.disappearEnd, 111 0.0f, 1.0f, 112 goneProgress) 113 remappedProgress = MathUtils.constrain(remappedProgress, 0.0f, 1.0f) 114 val result = viewState.copy(reusedState).apply { 115 width = MathUtils.lerp( 116 viewState.width.toFloat(), 117 viewState.width * disappearParameters.disappearSize.x, 118 remappedProgress).toInt() 119 height = MathUtils.lerp( 120 viewState.height.toFloat(), 121 viewState.height * disappearParameters.disappearSize.y, 122 remappedProgress).toInt() 123 translation.x = (viewState.width - width) * disappearParameters.gonePivot.x 124 translation.y = (viewState.height - height) * disappearParameters.gonePivot.y 125 contentTranslation.x = (disappearParameters.contentTranslationFraction.x - 1.0f) * 126 translation.x 127 contentTranslation.y = (disappearParameters.contentTranslationFraction.y - 1.0f) * 128 translation.y 129 val alphaProgress = MathUtils.map( 130 disappearParameters.fadeStartPosition, 1.0f, 1.0f, 0.0f, remappedProgress) 131 alpha = MathUtils.constrain(alphaProgress, 0.0f, 1.0f) 132 } 133 return result 134 } 135 136 /** 137 * Get an interpolated state between two viewstates. This interpolates all positions for all 138 * widgets as well as it's bounds based on the given input. 139 */ getInterpolatedStatenull140 fun getInterpolatedState( 141 startState: TransitionViewState, 142 endState: TransitionViewState, 143 progress: Float, 144 reusedState: TransitionViewState? = null 145 ): TransitionViewState { 146 val resultState = reusedState ?: TransitionViewState() 147 val view = transitionLayout ?: return resultState 148 val childCount = view.childCount 149 for (i in 0 until childCount) { 150 val id = view.getChildAt(i).id 151 val resultWidgetState = resultState.widgetStates[id] ?: WidgetState() 152 val widgetStart = startState.widgetStates[id] ?: continue 153 val widgetEnd = endState.widgetStates[id] ?: continue 154 var alphaProgress = progress 155 var widthProgress = progress 156 val resultMeasureWidth: Int 157 val resultMeasureHeight: Int 158 val newScale: Float 159 val resultX: Float 160 val resultY: Float 161 if (widgetStart.gone != widgetEnd.gone) { 162 // A view is appearing or disappearing. Let's not just interpolate between them as 163 // this looks quite ugly 164 val nowGone: Boolean 165 if (widgetStart.gone) { 166 // don't clip 167 widthProgress = 1.0f 168 169 // Let's directly measure it with the end state 170 resultMeasureWidth = widgetEnd.measureWidth 171 resultMeasureHeight = widgetEnd.measureHeight 172 173 if (isGutsAnimation) { 174 // if animation is open/close guts, fade in starts early. 175 alphaProgress = MathUtils.map( 176 GONE_FADE_GUTS_FRACTION, 177 1.0f, 178 0.0f, 179 1.0f, 180 progress 181 ) 182 nowGone = progress < GONE_FADE_GUTS_FRACTION 183 184 // Do not change scale of widget. 185 newScale = 1.0f 186 187 // We do not want any horizontal or vertical movement. 188 resultX = widgetStart.x 189 resultY = widgetStart.y 190 } else { 191 // Only fade it in at the very end 192 alphaProgress = MathUtils.map( 193 GONE_FADE_FRACTION, 194 1.0f, 195 0.0f, 196 1.0f, 197 progress 198 ) 199 nowGone = progress < GONE_FADE_FRACTION 200 201 // Scale it just a little, not all the way 202 val endScale = widgetEnd.scale 203 newScale = MathUtils.lerp(GONE_SCALE_AMOUNT * endScale, endScale, progress) 204 205 // Let's make sure we're centering the view in the gone view instead of 206 // having the left at 0 207 resultX = MathUtils.lerp( 208 widgetStart.x - resultMeasureWidth / 2.0f, 209 widgetEnd.x, 210 progress 211 ) 212 resultY = MathUtils.lerp( 213 widgetStart.y - resultMeasureHeight / 2.0f, 214 widgetEnd.y, 215 progress 216 ) 217 } 218 } else { 219 // Don't clip 220 widthProgress = 0.0f 221 222 // Let's directly measure it with the start state 223 resultMeasureWidth = widgetStart.measureWidth 224 resultMeasureHeight = widgetStart.measureHeight 225 226 // Fadeout in the very beginning 227 if (isGutsAnimation) { 228 alphaProgress = MathUtils.map( 229 0.0f, 230 VISIBLE_FADE_GUTS_FRACTION, 231 0.0f, 232 1.0f, 233 progress 234 ) 235 nowGone = progress > VISIBLE_FADE_GUTS_FRACTION 236 237 // Do not change scale of widget during open/close guts animation. 238 newScale = 1.0f 239 240 // We do not want any horizontal or vertical movement. 241 resultX = widgetEnd.x 242 resultY = widgetEnd.y 243 } else { 244 alphaProgress = MathUtils.map( 245 0.0f, 246 1.0f - GONE_FADE_FRACTION, 247 0.0f, 248 1.0f, 249 progress 250 ) 251 nowGone = progress > 1.0f - GONE_FADE_FRACTION 252 253 // Scale it just a little, not all the way 254 val startScale = widgetStart.scale 255 newScale = MathUtils.lerp( 256 startScale, 257 startScale * GONE_SCALE_AMOUNT, 258 progress 259 ) 260 261 // Let's make sure we're centering the view in the gone view instead of 262 // having the left at 0 263 resultX = MathUtils.lerp( 264 widgetStart.x, 265 widgetEnd.x - resultMeasureWidth / 2.0f, 266 progress 267 ) 268 resultY = MathUtils.lerp( 269 widgetStart.y, 270 widgetEnd.y - resultMeasureHeight / 2.0f, 271 progress 272 ) 273 } 274 } 275 resultWidgetState.gone = nowGone 276 } else { 277 resultWidgetState.gone = widgetStart.gone 278 // Let's directly measure it with the end state 279 resultMeasureWidth = widgetEnd.measureWidth 280 resultMeasureHeight = widgetEnd.measureHeight 281 newScale = MathUtils.lerp(widgetStart.scale, widgetEnd.scale, progress) 282 resultX = MathUtils.lerp(widgetStart.x, widgetEnd.x, progress) 283 resultY = MathUtils.lerp(widgetStart.y, widgetEnd.y, progress) 284 } 285 resultWidgetState.apply { 286 x = resultX 287 y = resultY 288 alpha = MathUtils.lerp(widgetStart.alpha, widgetEnd.alpha, alphaProgress) 289 width = MathUtils.lerp(widgetStart.width.toFloat(), widgetEnd.width.toFloat(), 290 widthProgress).toInt() 291 height = MathUtils.lerp(widgetStart.height.toFloat(), widgetEnd.height.toFloat(), 292 widthProgress).toInt() 293 scale = newScale 294 295 // Let's directly measure it with the end state 296 measureWidth = resultMeasureWidth 297 measureHeight = resultMeasureHeight 298 } 299 resultState.widgetStates[id] = resultWidgetState 300 } 301 resultState.apply { 302 width = MathUtils.lerp(startState.width.toFloat(), endState.width.toFloat(), 303 progress).toInt() 304 height = MathUtils.lerp(startState.height.toFloat(), endState.height.toFloat(), 305 progress).toInt() 306 // If we're at the start, let's measure with the starting dimensions, otherwise always 307 // with the end state 308 if (progress == 0.0f) { 309 measureWidth = startState.measureWidth 310 measureHeight = startState.measureHeight 311 } else { 312 measureWidth = endState.measureWidth 313 measureHeight = endState.measureHeight 314 } 315 translation.x = MathUtils.lerp(startState.translation.x, endState.translation.x, 316 progress) 317 translation.y = MathUtils.lerp(startState.translation.y, endState.translation.y, 318 progress) 319 alpha = MathUtils.lerp(startState.alpha, endState.alpha, progress) 320 contentTranslation.x = MathUtils.lerp( 321 startState.contentTranslation.x, 322 endState.contentTranslation.x, 323 progress) 324 contentTranslation.y = MathUtils.lerp( 325 startState.contentTranslation.y, 326 endState.contentTranslation.y, 327 progress) 328 } 329 return resultState 330 } 331 attachnull332 fun attach(transitionLayout: TransitionLayout) { 333 this.transitionLayout = transitionLayout 334 } 335 336 /** 337 * Set a new state to be applied to the dynamic view. 338 * 339 * @param state the state to be applied 340 * @param animate should this change be animated. If [false] the we will either apply the 341 * state immediately if no animation is running, and if one is running, we will update the end 342 * value to match the new state. 343 * @param applyImmediately should this change be applied immediately, canceling all running 344 * animations 345 */ setStatenull346 fun setState( 347 state: TransitionViewState, 348 applyImmediately: Boolean, 349 animate: Boolean, 350 duration: Long = 0, 351 delay: Long = 0, 352 isGuts: Boolean, 353 ) { 354 isGutsAnimation = isGuts 355 val animated = animate && currentState.width != 0 356 this.state = state.copy() 357 if (applyImmediately || transitionLayout == null) { 358 animator.cancel() 359 applyStateToLayout(this.state) 360 currentState = state.copy(reusedState = currentState) 361 } else if (animated) { 362 animationStartState = currentState.copy() 363 animator.duration = duration 364 animator.startDelay = delay 365 animator.interpolator = 366 if (isGutsAnimation) Interpolators.LINEAR else Interpolators.FAST_OUT_SLOW_IN 367 animator.start() 368 } else if (!animator.isRunning) { 369 applyStateToLayout(this.state) 370 currentState = state.copy(reusedState = currentState) 371 } 372 // otherwise the desired state was updated and the animation will go to the new target 373 } 374 375 /** 376 * Set a new state that will be used to measure the view itself and is useful during 377 * transitions, where the state set via [setState] may differ from how the view 378 * should be measured. 379 */ setMeasureStatenull380 fun setMeasureState( 381 state: TransitionViewState 382 ) { 383 transitionLayout?.measureState = state 384 } 385 } 386 387 class DisappearParameters() { 388 389 /** 390 * The pivot point when clipping view when disappearing, which describes how the content will 391 * be translated. 392 * The default value of (0.0f, 1.0f) means that the view will not be translated in horizontally 393 * and the vertical disappearing will be aligned on the bottom of the view, 394 */ 395 var gonePivot = PointF(0.0f, 1.0f) 396 397 /** 398 * The fraction of the width and height that will remain when disappearing. The default of 399 * (1.0f, 0.0f) means that 100% of the width, but 0% of the height will remain at the end of 400 * the transition. 401 */ 402 var disappearSize = PointF(1.0f, 0.0f) 403 404 /** 405 * The fraction of the normal translation, by which the content will be moved during the 406 * disappearing. The values here can be both negative as well as positive. The default value 407 * of (0.0f, 0.2f) means that the content doesn't move horizontally but moves 20% of the 408 * translation imposed by the pivot downwards. 1.0f means that the content will be translated 409 * in sync with the translation of the bounds 410 */ 411 var contentTranslationFraction = PointF(0.0f, 0.8f) 412 413 /** 414 * The point during the progress from [0.0, 1.0f] where the view is fully appeared. 0.0f 415 * means that the content will start disappearing immediately, while 0.5f means that it 416 * starts disappearing half way through the progress. 417 */ 418 var disappearStart = 0.0f 419 420 /** 421 * The point during the progress from [0.0, 1.0f] where the view has fully disappeared. 1.0f 422 * means that the view will disappear in sync with the progress, while 0.5f means that it 423 * is fully gone half way through the progress. 424 */ 425 var disappearEnd = 1.0f 426 427 /** 428 * The point during the mapped progress from [0.0, 1.0f] where the view starts fading out. 1.0f 429 * means that the view doesn't fade at all, while 0.5 means that the content fades starts 430 * fading at the midpoint between [disappearStart] and [disappearEnd] 431 */ 432 var fadeStartPosition = 0.9f 433 equalsnull434 override fun equals(other: Any?): Boolean { 435 if (!(other is DisappearParameters)) { 436 return false 437 } 438 if (!disappearSize.equals(other.disappearSize)) { 439 return false 440 } 441 if (!gonePivot.equals(other.gonePivot)) { 442 return false 443 } 444 if (!contentTranslationFraction.equals(other.contentTranslationFraction)) { 445 return false 446 } 447 if (disappearStart != other.disappearStart) { 448 return false 449 } 450 if (disappearEnd != other.disappearEnd) { 451 return false 452 } 453 if (fadeStartPosition != other.fadeStartPosition) { 454 return false 455 } 456 return true 457 } 458 hashCodenull459 override fun hashCode(): Int { 460 var result = disappearSize.hashCode() 461 result = 31 * result + gonePivot.hashCode() 462 result = 31 * result + contentTranslationFraction.hashCode() 463 result = 31 * result + disappearStart.hashCode() 464 result = 31 * result + disappearEnd.hashCode() 465 result = 31 * result + fadeStartPosition.hashCode() 466 return result 467 } 468 deepCopynull469 fun deepCopy(): DisappearParameters { 470 val result = DisappearParameters() 471 result.disappearSize.set(disappearSize) 472 result.gonePivot.set(gonePivot) 473 result.contentTranslationFraction.set(contentTranslationFraction) 474 result.disappearStart = disappearStart 475 result.disappearEnd = disappearEnd 476 result.fadeStartPosition = fadeStartPosition 477 return result 478 } 479 } 480