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