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