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