Selim Cinek | 2de5ebb | 2020-05-20 15:39:03 -0700 | [diff] [blame] | 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 |
Lucas Dupin | 84f5a0e | 2020-06-08 19:55:33 -0700 | [diff] [blame] | 20 | import android.graphics.PointF |
Selim Cinek | 2de5ebb | 2020-05-20 15:39:03 -0700 | [diff] [blame] | 21 | import android.util.MathUtils |
| 22 | import com.android.systemui.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() |
Lucas Dupin | 84f5a0e | 2020-06-08 19:55:33 -0700 | [diff] [blame] | 47 | private var pivot = PointF() |
Selim Cinek | 2de5ebb | 2020-05-20 15:39:03 -0700 | [diff] [blame] | 48 | private var animator: ValueAnimator = ValueAnimator.ofFloat(0.0f, 1.0f) |
Selim Cinek | afae4e7 | 2020-06-16 18:21:41 -0700 | [diff] [blame] | 49 | private var currentHeight: Int = 0 |
| 50 | private var currentWidth: Int = 0 |
| 51 | var sizeChangedListener: ((Int, Int) -> Unit)? = null |
Selim Cinek | 2de5ebb | 2020-05-20 15:39:03 -0700 | [diff] [blame] | 52 | |
| 53 | init { |
| 54 | animator.apply { |
| 55 | addUpdateListener { |
| 56 | updateStateFromAnimation() |
| 57 | } |
| 58 | interpolator = Interpolators.FAST_OUT_SLOW_IN |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | private fun updateStateFromAnimation() { |
| 63 | if (animationStartState == null || !animator.isRunning) { |
| 64 | return |
| 65 | } |
| 66 | val view = transitionLayout ?: return |
| 67 | getInterpolatedState( |
| 68 | startState = animationStartState!!, |
| 69 | endState = state, |
| 70 | progress = animator.animatedFraction, |
Lucas Dupin | 84f5a0e | 2020-06-08 19:55:33 -0700 | [diff] [blame] | 71 | pivot = pivot, |
Selim Cinek | 2de5ebb | 2020-05-20 15:39:03 -0700 | [diff] [blame] | 72 | resultState = currentState) |
Selim Cinek | afae4e7 | 2020-06-16 18:21:41 -0700 | [diff] [blame] | 73 | applyStateToLayout(currentState) |
| 74 | } |
| 75 | |
| 76 | private fun applyStateToLayout(state: TransitionViewState) { |
| 77 | transitionLayout?.setState(state) |
| 78 | if (currentHeight != state.height || currentWidth != state.width) { |
| 79 | currentHeight = state.height |
| 80 | currentWidth = state.width |
| 81 | sizeChangedListener?.invoke(currentWidth, currentHeight) |
| 82 | } |
Selim Cinek | 2de5ebb | 2020-05-20 15:39:03 -0700 | [diff] [blame] | 83 | } |
| 84 | |
| 85 | /** |
| 86 | * Get an interpolated state between two viewstates. This interpolates all positions for all |
| 87 | * widgets as well as it's bounds based on the given input. |
| 88 | */ |
| 89 | fun getInterpolatedState( |
| 90 | startState: TransitionViewState, |
| 91 | endState: TransitionViewState, |
| 92 | progress: Float, |
Lucas Dupin | 84f5a0e | 2020-06-08 19:55:33 -0700 | [diff] [blame] | 93 | pivot: PointF, |
Selim Cinek | 2de5ebb | 2020-05-20 15:39:03 -0700 | [diff] [blame] | 94 | resultState: TransitionViewState |
| 95 | ) { |
Lucas Dupin | 84f5a0e | 2020-06-08 19:55:33 -0700 | [diff] [blame] | 96 | this.pivot.set(pivot) |
Selim Cinek | 2de5ebb | 2020-05-20 15:39:03 -0700 | [diff] [blame] | 97 | val view = transitionLayout ?: return |
| 98 | val childCount = view.childCount |
| 99 | for (i in 0 until childCount) { |
| 100 | val id = view.getChildAt(i).id |
| 101 | val resultWidgetState = resultState.widgetStates[id] ?: WidgetState() |
| 102 | val widgetStart = startState.widgetStates[id] ?: continue |
| 103 | val widgetEnd = endState.widgetStates[id] ?: continue |
| 104 | var alphaProgress = progress |
| 105 | var widthProgress = progress |
| 106 | val resultMeasureWidth: Int |
| 107 | val resultMeasureHeight: Int |
| 108 | val newScale: Float |
| 109 | val resultX: Float |
| 110 | val resultY: Float |
| 111 | if (widgetStart.gone != widgetEnd.gone) { |
| 112 | // A view is appearing or disappearing. Let's not just interpolate between them as |
| 113 | // this looks quite ugly |
| 114 | val nowGone: Boolean |
| 115 | if (widgetStart.gone) { |
| 116 | |
| 117 | // Only fade it in at the very end |
| 118 | alphaProgress = MathUtils.map(GONE_FADE_FRACTION, 1.0f, 0.0f, 1.0f, progress) |
| 119 | nowGone = progress < GONE_FADE_FRACTION |
| 120 | |
| 121 | // Scale it just a little, not all the way |
| 122 | val endScale = widgetEnd.scale |
| 123 | newScale = MathUtils.lerp(GONE_SCALE_AMOUNT * endScale, endScale, progress) |
| 124 | |
| 125 | // don't clip |
| 126 | widthProgress = 1.0f |
| 127 | |
| 128 | // Let's directly measure it with the end state |
| 129 | resultMeasureWidth = widgetEnd.measureWidth |
| 130 | resultMeasureHeight = widgetEnd.measureHeight |
| 131 | |
| 132 | // Let's make sure we're centering the view in the gone view instead of having |
| 133 | // the left at 0 |
| 134 | resultX = MathUtils.lerp(widgetStart.x - resultMeasureWidth / 2.0f, |
| 135 | widgetEnd.x, |
| 136 | progress) |
| 137 | resultY = MathUtils.lerp(widgetStart.y - resultMeasureHeight / 2.0f, |
| 138 | widgetEnd.y, |
| 139 | progress) |
| 140 | } else { |
| 141 | |
| 142 | // Fadeout in the very beginning |
| 143 | alphaProgress = MathUtils.map(0.0f, 1.0f - GONE_FADE_FRACTION, 0.0f, 1.0f, |
| 144 | progress) |
| 145 | nowGone = progress > 1.0f - GONE_FADE_FRACTION |
| 146 | |
| 147 | // Scale it just a little, not all the way |
| 148 | val startScale = widgetStart.scale |
| 149 | newScale = MathUtils.lerp(startScale, startScale * GONE_SCALE_AMOUNT, progress) |
| 150 | |
| 151 | // Don't clip |
| 152 | widthProgress = 0.0f |
| 153 | |
| 154 | // Let's directly measure it with the start state |
| 155 | resultMeasureWidth = widgetStart.measureWidth |
| 156 | resultMeasureHeight = widgetStart.measureHeight |
| 157 | |
| 158 | // Let's make sure we're centering the view in the gone view instead of having |
| 159 | // the left at 0 |
| 160 | resultX = MathUtils.lerp(widgetStart.x, |
| 161 | widgetEnd.x - resultMeasureWidth / 2.0f, |
| 162 | progress) |
| 163 | resultY = MathUtils.lerp(widgetStart.y, |
| 164 | widgetEnd.y - resultMeasureHeight / 2.0f, |
| 165 | progress) |
| 166 | } |
| 167 | resultWidgetState.gone = nowGone |
| 168 | } else { |
| 169 | resultWidgetState.gone = widgetStart.gone |
| 170 | // Let's directly measure it with the end state |
| 171 | resultMeasureWidth = widgetEnd.measureWidth |
| 172 | resultMeasureHeight = widgetEnd.measureHeight |
| 173 | newScale = MathUtils.lerp(widgetStart.scale, widgetEnd.scale, progress) |
| 174 | resultX = MathUtils.lerp(widgetStart.x, widgetEnd.x, progress) |
| 175 | resultY = MathUtils.lerp(widgetStart.y, widgetEnd.y, progress) |
| 176 | } |
| 177 | resultWidgetState.apply { |
| 178 | x = resultX |
| 179 | y = resultY |
| 180 | alpha = MathUtils.lerp(widgetStart.alpha, widgetEnd.alpha, alphaProgress) |
| 181 | width = MathUtils.lerp(widgetStart.width.toFloat(), widgetEnd.width.toFloat(), |
| 182 | widthProgress).toInt() |
| 183 | height = MathUtils.lerp(widgetStart.height.toFloat(), widgetEnd.height.toFloat(), |
| 184 | widthProgress).toInt() |
| 185 | scale = newScale |
| 186 | |
| 187 | // Let's directly measure it with the end state |
| 188 | measureWidth = resultMeasureWidth |
| 189 | measureHeight = resultMeasureHeight |
| 190 | } |
| 191 | resultState.widgetStates[id] = resultWidgetState |
| 192 | } |
| 193 | resultState.apply { |
| 194 | width = MathUtils.lerp(startState.width.toFloat(), endState.width.toFloat(), |
| 195 | progress).toInt() |
| 196 | height = MathUtils.lerp(startState.height.toFloat(), endState.height.toFloat(), |
| 197 | progress).toInt() |
Lucas Dupin | 84f5a0e | 2020-06-08 19:55:33 -0700 | [diff] [blame] | 198 | translation.x = (endState.width - width) * pivot.x |
| 199 | translation.y = (endState.height - height) * pivot.y |
Selim Cinek | 2de5ebb | 2020-05-20 15:39:03 -0700 | [diff] [blame] | 200 | } |
| 201 | } |
| 202 | |
| 203 | fun attach(transitionLayout: TransitionLayout) { |
| 204 | this.transitionLayout = transitionLayout |
| 205 | } |
| 206 | |
| 207 | /** |
| 208 | * Set a new state to be applied to the dynamic view. |
| 209 | * |
| 210 | * @param state the state to be applied |
| 211 | * @param animate should this change be animated. If [false] the we will either apply the |
| 212 | * state immediately if no animation is running, and if one is running, we will update the end |
| 213 | * value to match the new state. |
| 214 | * @param applyImmediately should this change be applied immediately, canceling all running |
| 215 | * animations |
| 216 | */ |
| 217 | fun setState( |
| 218 | state: TransitionViewState, |
| 219 | applyImmediately: Boolean, |
| 220 | animate: Boolean, |
| 221 | duration: Long = 0, |
| 222 | delay: Long = 0 |
| 223 | ) { |
| 224 | val animated = animate && currentState.width != 0 |
| 225 | this.state = state.copy() |
| 226 | if (applyImmediately || transitionLayout == null) { |
| 227 | animator.cancel() |
Selim Cinek | afae4e7 | 2020-06-16 18:21:41 -0700 | [diff] [blame] | 228 | applyStateToLayout(this.state) |
Selim Cinek | 2de5ebb | 2020-05-20 15:39:03 -0700 | [diff] [blame] | 229 | currentState = state.copy(reusedState = currentState) |
| 230 | } else if (animated) { |
| 231 | animationStartState = currentState.copy() |
| 232 | animator.duration = duration |
| 233 | animator.startDelay = delay |
| 234 | animator.start() |
| 235 | } else if (!animator.isRunning) { |
Selim Cinek | afae4e7 | 2020-06-16 18:21:41 -0700 | [diff] [blame] | 236 | applyStateToLayout(this.state) |
Selim Cinek | 2de5ebb | 2020-05-20 15:39:03 -0700 | [diff] [blame] | 237 | currentState = state.copy(reusedState = currentState) |
| 238 | } |
| 239 | // otherwise the desired state was updated and the animation will go to the new target |
| 240 | } |
| 241 | |
| 242 | /** |
| 243 | * Set a new state that will be used to measure the view itself and is useful during |
| 244 | * transitions, where the state set via [setState] may differ from how the view |
| 245 | * should be measured. |
| 246 | */ |
| 247 | fun setMeasureState( |
| 248 | state: TransitionViewState |
| 249 | ) { |
| 250 | transitionLayout?.measureState = state |
| 251 | } |
| 252 | } |