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 /**
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 {
53         animator.apply {
54             addUpdateListener {
55                 updateStateFromAnimation()
56             }
57             interpolator = Interpolators.FAST_OUT_SLOW_IN
58         }
59     }
60 
61     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 
73     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      */
91     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      */
129     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             // If we're at the start, let's measure with the starting dimensions, otherwise always
238             // with the end state
239             if (progress == 0.0f) {
240                 measureWidth = startState.measureWidth
241                 measureHeight = startState.measureHeight
242             } else {
243                 measureWidth = endState.measureWidth
244                 measureHeight = endState.measureHeight
245             }
246             translation.x = MathUtils.lerp(startState.translation.x, endState.translation.x,
247                     progress)
248             translation.y = MathUtils.lerp(startState.translation.y, endState.translation.y,
249                     progress)
250             alpha = MathUtils.lerp(startState.alpha, endState.alpha, progress)
251             contentTranslation.x = MathUtils.lerp(
252                     startState.contentTranslation.x,
253                     endState.contentTranslation.x,
254                     progress)
255             contentTranslation.y = MathUtils.lerp(
256                     startState.contentTranslation.y,
257                     endState.contentTranslation.y,
258                     progress)
259         }
260         return resultState
261     }
262 
263     fun attach(transitionLayout: TransitionLayout) {
264         this.transitionLayout = transitionLayout
265     }
266 
267     /**
268      * Set a new state to be applied to the dynamic view.
269      *
270      * @param state the state to be applied
271      * @param animate should this change be animated. If [false] the we will either apply the
272      * state immediately if no animation is running, and if one is running, we will update the end
273      * value to match the new state.
274      * @param applyImmediately should this change be applied immediately, canceling all running
275      * animations
276      */
277     fun setState(
278         state: TransitionViewState,
279         applyImmediately: Boolean,
280         animate: Boolean,
281         duration: Long = 0,
282         delay: Long = 0
283     ) {
284         val animated = animate && currentState.width != 0
285         this.state = state.copy()
286         if (applyImmediately || transitionLayout == null) {
287             animator.cancel()
288             applyStateToLayout(this.state)
289             currentState = state.copy(reusedState = currentState)
290         } else if (animated) {
291             animationStartState = currentState.copy()
292             animator.duration = duration
293             animator.startDelay = delay
294             animator.start()
295         } else if (!animator.isRunning) {
296             applyStateToLayout(this.state)
297             currentState = state.copy(reusedState = currentState)
298         }
299         // otherwise the desired state was updated and the animation will go to the new target
300     }
301 
302     /**
303      * Set a new state that will be used to measure the view itself and is useful during
304      * transitions, where the state set via [setState] may differ from how the view
305      * should be measured.
306      */
307     fun setMeasureState(
308         state: TransitionViewState
309     ) {
310         transitionLayout?.measureState = state
311     }
312 }
313 
314 class DisappearParameters() {
315 
316     /**
317      * The pivot point when clipping view when disappearing, which describes how the content will
318      * be translated.
319      * The default value of (0.0f, 1.0f) means that the view will not be translated in horizontally
320      * and the vertical disappearing will be aligned on the bottom of the view,
321      */
322     var gonePivot = PointF(0.0f, 1.0f)
323 
324     /**
325      * The fraction of the width and height that will remain when disappearing. The default of
326      * (1.0f, 0.0f) means that 100% of the width, but 0% of the height will remain at the end of
327      * the transition.
328      */
329     var disappearSize = PointF(1.0f, 0.0f)
330 
331     /**
332      * The fraction of the normal translation, by which the content will be moved during the
333      * disappearing. The values here can be both negative as well as positive. The default value
334      * of (0.0f, 0.2f) means that the content doesn't move horizontally but moves 20% of the
335      * translation imposed by the pivot downwards. 1.0f means that the content will be translated
336      * in sync with the translation of the bounds
337      */
338     var contentTranslationFraction = PointF(0.0f, 0.8f)
339 
340     /**
341      * The point during the progress from [0.0, 1.0f] where the view is fully appeared. 0.0f
342      * means that the content will start disappearing immediately, while 0.5f means that it
343      * starts disappearing half way through the progress.
344      */
345     var disappearStart = 0.0f
346 
347     /**
348      * The point during the progress from [0.0, 1.0f] where the view has fully disappeared. 1.0f
349      * means that the view will disappear in sync with the progress, while 0.5f means that it
350      * is fully gone half way through the progress.
351      */
352     var disappearEnd = 1.0f
353 
354     /**
355      * The point during the mapped progress from [0.0, 1.0f] where the view starts fading out. 1.0f
356      * means that the view doesn't fade at all, while 0.5 means that the content fades starts
357      * fading at the midpoint between [disappearStart] and [disappearEnd]
358      */
359     var fadeStartPosition = 0.9f
360 
361     override fun equals(other: Any?): Boolean {
362         if (!(other is DisappearParameters)) {
363             return false
364         }
365         if (!disappearSize.equals(other.disappearSize)) {
366             return false
367         }
368         if (!gonePivot.equals(other.gonePivot)) {
369             return false
370         }
371         if (!contentTranslationFraction.equals(other.contentTranslationFraction)) {
372             return false
373         }
374         if (disappearStart != other.disappearStart) {
375             return false
376         }
377         if (disappearEnd != other.disappearEnd) {
378             return false
379         }
380         if (fadeStartPosition != other.fadeStartPosition) {
381             return false
382         }
383         return true
384     }
385 
386     override fun hashCode(): Int {
387         var result = disappearSize.hashCode()
388         result = 31 * result + gonePivot.hashCode()
389         result = 31 * result + contentTranslationFraction.hashCode()
390         result = 31 * result + disappearStart.hashCode()
391         result = 31 * result + disappearEnd.hashCode()
392         result = 31 * result + fadeStartPosition.hashCode()
393         return result
394     }
395 
396     fun deepCopy(): DisappearParameters {
397         val result = DisappearParameters()
398         result.disappearSize.set(disappearSize)
399         result.gonePivot.set(gonePivot)
400         result.contentTranslationFraction.set(contentTranslationFraction)
401         result.disappearStart = disappearStart
402         result.disappearEnd = disappearEnd
403         result.fadeStartPosition = fadeStartPosition
404         return result
405     }
406 }
407