1 /*
2  * Copyright 2023 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.compose.animation.scene
18 
19 import androidx.compose.animation.core.AnimationSpec
20 import androidx.compose.animation.core.SpringSpec
21 import androidx.compose.foundation.gestures.Orientation
22 import androidx.compose.ui.geometry.Offset
23 import androidx.compose.ui.unit.Density
24 import androidx.compose.ui.unit.Dp
25 import androidx.compose.ui.unit.dp
26 import com.android.compose.animation.scene.TransitionState.HasOverscrollProperties.Companion.DistanceUnspecified
27 
28 /** Define the [transitions][SceneTransitions] to be used with a [SceneTransitionLayout]. */
transitionsnull29 fun transitions(builder: SceneTransitionsBuilder.() -> Unit): SceneTransitions {
30     return transitionsImpl(builder)
31 }
32 
33 @DslMarker annotation class TransitionDsl
34 
35 @TransitionDsl
36 interface SceneTransitionsBuilder {
37     /**
38      * The default [AnimationSpec] used when after the user lifts their finger after starting a
39      * swipe to transition, to animate back into one of the 2 scenes we are transitioning to.
40      */
41     var defaultSwipeSpec: SpringSpec<Float>
42 
43     /**
44      * The [InterruptionHandler] used when transitions are interrupted. Defaults to
45      * [DefaultInterruptionHandler].
46      */
47     var interruptionHandler: InterruptionHandler
48 
49     /**
50      * Define the default animation to be played when transitioning [to] the specified scene, from
51      * any scene. For the animation specification to apply only when transitioning between two
52      * specific scenes, use [from] instead.
53      *
54      * If [key] is not `null`, then this transition will only be used if the same key is specified
55      * when triggering the transition.
56      *
57      * @see from
58      */
tonull59     fun to(
60         to: SceneKey,
61         key: TransitionKey? = null,
62         builder: TransitionBuilder.() -> Unit = {},
63     ): TransitionSpec
64 
65     /**
66      * Define the animation to be played when transitioning [from] the specified scene. For the
67      * animation specification to apply only when transitioning between two specific scenes, pass
68      * the destination scene via the [to] argument.
69      *
70      * When looking up which transition should be used when animating from scene A to scene B, we
71      * pick the single transition with the given [key] and matching one of these predicates (in
72      * order of importance):
73      * 1. from == A && to == B
74      * 2. to == A && from == B, which is then treated in reverse.
75      * 3. (from == A && to == null) || (from == null && to == B)
76      * 4. (from == B && to == null) || (from == null && to == A), which is then treated in reverse.
77      */
fromnull78     fun from(
79         from: SceneKey,
80         to: SceneKey? = null,
81         key: TransitionKey? = null,
82         builder: TransitionBuilder.() -> Unit = {},
83     ): TransitionSpec
84 
85     /**
86      * Define the animation to be played when the [scene] is overscrolled in the given
87      * [orientation].
88      *
89      * The overscroll animation always starts from a progress of 0f, and reaches 1f when moving the
90      * [distance] down/right, -1f when moving in the opposite direction.
91      */
overscrollnull92     fun overscroll(
93         scene: SceneKey,
94         orientation: Orientation,
95         builder: OverscrollBuilder.() -> Unit = {},
96     ): OverscrollSpec
97 }
98 
99 interface BaseTransitionBuilder : PropertyTransformationBuilder {
100     /**
101      * The distance it takes for this transition to animate from 0% to 100% when it is driven by a
102      * [UserAction].
103      *
104      * If `null`, a default distance will be used that depends on the [UserAction] performed.
105      */
106     var distance: UserActionDistance?
107 
108     /**
109      * Define a progress-based range for the transformations inside [builder].
110      *
111      * For instance, the following will fade `Foo` during the first half of the transition then it
112      * will translate it by 100.dp during the second half.
113      *
114      * ```
115      * fractionRange(end = 0.5f) { fade(Foo) }
116      * fractionRange(start = 0.5f) { translate(Foo, x = 100.dp) }
117      * ```
118      *
119      * @param start the start of the range, in the [0; 1] range.
120      * @param end the end of the range, in the [0; 1] range.
121      */
fractionRangenull122     fun fractionRange(
123         start: Float? = null,
124         end: Float? = null,
125         builder: PropertyTransformationBuilder.() -> Unit,
126     )
127 }
128 
129 @TransitionDsl
130 interface TransitionBuilder : BaseTransitionBuilder {
131     /**
132      * The [AnimationSpec] used to animate the associated transition progress from `0` to `1` when
133      * the transition is triggered (i.e. it is not gesture-based).
134      */
135     var spec: AnimationSpec<Float>
136 
137     /**
138      * The [SpringSpec] used to animate the associated transition progress when the transition was
139      * started by a swipe and is now animating back to a scene because the user lifted their finger.
140      *
141      * If `null`, then the [SceneTransitionsBuilder.defaultSwipeSpec] will be used.
142      */
143     var swipeSpec: SpringSpec<Float>?
144 
145     /**
146      * Define a timestamp-based range for the transformations inside [builder].
147      *
148      * For instance, the following will fade `Foo` during the first half of the transition then it
149      * will translate it by 100.dp during the second half.
150      *
151      * ```
152      * spec = tween(500)
153      * timestampRange(end = 250) { fade(Foo) }
154      * timestampRange(start = 250) { translate(Foo, x = 100.dp) }
155      * ```
156      *
157      * Important: [spec] must be a [androidx.compose.animation.core.DurationBasedAnimationSpec] if
158      * you call [timestampRange], otherwise this will throw. The spec duration will be used to
159      * transform this range into a [fractionRange].
160      *
161      * @param startMillis the start of the range, in the [0; spec.duration] range.
162      * @param endMillis the end of the range, in the [0; spec.duration] range.
163      */
164     fun timestampRange(
165         startMillis: Int? = null,
166         endMillis: Int? = null,
167         builder: PropertyTransformationBuilder.() -> Unit,
168     )
169 
170     /**
171      * Configure the shared transition when [matcher] is shared between two scenes.
172      *
173      * @param enabled whether the matched element(s) should actually be shared in this transition.
174      *   Defaults to true.
175      */
176     fun sharedElement(matcher: ElementMatcher, enabled: Boolean = true)
177 
178     /**
179      * Adds the transformations in [builder] but in reversed order. This allows you to partially
180      * reuse the definition of the transition from scene `Foo` to scene `Bar` inside the definition
181      * of the transition from scene `Bar` to scene `Foo`.
182      */
183     fun reversed(builder: TransitionBuilder.() -> Unit)
184 }
185 
186 @TransitionDsl
187 interface OverscrollBuilder : BaseTransitionBuilder {
188     /** Translate the element(s) matching [matcher] by ([x], [y]) pixels. */
translatenull189     fun translate(
190         matcher: ElementMatcher,
191         x: OverscrollScope.() -> Float = { 0f },
<lambda>null192         y: OverscrollScope.() -> Float = { 0f },
193     )
194 }
195 
196 interface OverscrollScope : Density {
197     /**
198      * Return the absolute distance between fromScene and toScene, if available, otherwise
199      * [DistanceUnspecified].
200      */
201     val absoluteDistance: Float
202 }
203 
204 /**
205  * An interface to decide where we should draw shared Elements or compose MovableElements.
206  *
207  * @see DefaultElementScenePicker
208  * @see HighestZIndexScenePicker
209  * @see LowestZIndexScenePicker
210  * @see MovableElementScenePicker
211  */
212 interface ElementScenePicker {
213     /**
214      * Return the scene in which [element] should be drawn (when using `Modifier.element(key)`) or
215      * composed (when using `MovableElement(key)`) during the given [transition]. If this element
216      * should not be drawn or composed in neither [transition.fromScene] nor [transition.toScene],
217      * return `null`.
218      *
219      * Important: For [MovableElements][SceneScope.MovableElement], this scene picker will *always*
220      * be used during transitions to decide whether we should compose that element in a given scene
221      * or not. Therefore, you should make sure that the returned [SceneKey] contains the movable
222      * element, otherwise that element will not be composed in any scene during the transition.
223      */
sceneDuringTransitionnull224     fun sceneDuringTransition(
225         element: ElementKey,
226         transition: TransitionState.Transition,
227         fromSceneZIndex: Float,
228         toSceneZIndex: Float,
229     ): SceneKey?
230 
231     /**
232      * Return [transition.fromScene] if it is in [scenes] and [transition.toScene] is not, or return
233      * [transition.toScene] if it is in [scenes] and [transition.fromScene] is not. If neither
234      * [transition.fromScene] and [transition.toScene] are in [scenes], return `null`. If both
235      * [transition.fromScene] and [transition.toScene] are in [scenes], throw an exception.
236      *
237      * This function can be useful when computing the scene in which a movable element should be
238      * composed.
239      */
240     fun pickSingleSceneIn(
241         scenes: Set<SceneKey>,
242         transition: TransitionState.Transition,
243         element: ElementKey,
244     ): SceneKey? {
245         val fromScene = transition.fromScene
246         val toScene = transition.toScene
247         val fromSceneInScenes = scenes.contains(fromScene)
248         val toSceneInScenes = scenes.contains(toScene)
249 
250         return when {
251             fromSceneInScenes && toSceneInScenes -> {
252                 error(
253                     "Element $element can be in both $fromScene and $toScene. You should add a " +
254                         "special case for this transition before calling pickSingleSceneIn()."
255                 )
256             }
257             fromSceneInScenes -> fromScene
258             toSceneInScenes -> toScene
259             else -> null
260         }
261     }
262 }
263 
264 /** An [ElementScenePicker] that draws/composes elements in the scene with the highest z-order. */
265 object HighestZIndexScenePicker : ElementScenePicker {
sceneDuringTransitionnull266     override fun sceneDuringTransition(
267         element: ElementKey,
268         transition: TransitionState.Transition,
269         fromSceneZIndex: Float,
270         toSceneZIndex: Float
271     ): SceneKey {
272         return if (fromSceneZIndex > toSceneZIndex) {
273             transition.fromScene
274         } else {
275             transition.toScene
276         }
277     }
278 }
279 
280 /** An [ElementScenePicker] that draws/composes elements in the scene with the lowest z-order. */
281 object LowestZIndexScenePicker : ElementScenePicker {
sceneDuringTransitionnull282     override fun sceneDuringTransition(
283         element: ElementKey,
284         transition: TransitionState.Transition,
285         fromSceneZIndex: Float,
286         toSceneZIndex: Float
287     ): SceneKey {
288         return if (fromSceneZIndex < toSceneZIndex) {
289             transition.fromScene
290         } else {
291             transition.toScene
292         }
293     }
294 }
295 
296 /**
297  * An [ElementScenePicker] that draws/composes elements in the scene we are transitioning to, iff
298  * that scene is in [scenes].
299  *
300  * This picker can be useful for movable elements whose content size depends on its content (because
301  * it wraps it) in at least one scene. That way, the target size of the MovableElement will be
302  * computed in the scene we are going to and, given that this element was probably already composed
303  * in the scene we are going from before starting the transition, the interpolated size of the
304  * movable element during the transition should be correct.
305  *
306  * The downside of this picker is that the zIndex of the element when going from scene A to scene B
307  * is not the same as when going from scene B to scene A, so it's not usable in situations where
308  * z-ordering during the transition matters.
309  */
310 class MovableElementScenePicker(private val scenes: Set<SceneKey>) : ElementScenePicker {
sceneDuringTransitionnull311     override fun sceneDuringTransition(
312         element: ElementKey,
313         transition: TransitionState.Transition,
314         fromSceneZIndex: Float,
315         toSceneZIndex: Float,
316     ): SceneKey? {
317         return when {
318             scenes.contains(transition.toScene) -> transition.toScene
319             scenes.contains(transition.fromScene) -> transition.fromScene
320             else -> null
321         }
322     }
323 }
324 
325 /** The default [ElementScenePicker]. */
326 val DefaultElementScenePicker = HighestZIndexScenePicker
327 
328 @TransitionDsl
329 interface PropertyTransformationBuilder {
330     /**
331      * Fade the element(s) matching [matcher]. This will automatically fade in or fade out if the
332      * element is entering or leaving the scene, respectively.
333      */
fadenull334     fun fade(matcher: ElementMatcher)
335 
336     /** Translate the element(s) matching [matcher] by ([x], [y]) dp. */
337     fun translate(matcher: ElementMatcher, x: Dp = 0.dp, y: Dp = 0.dp)
338 
339     /**
340      * Translate the element(s) matching [matcher] from/to the [edge] of the [SceneTransitionLayout]
341      * animating it.
342      *
343      * If [startsOutsideLayoutBounds] is `true`, then the element will start completely outside of
344      * the layout bounds (i.e. none of it will be visible at progress = 0f if the layout clips its
345      * content). If it is `false`, then the element will start aligned with the edge of the layout
346      * (i.e. it will be completely visible at progress = 0f).
347      */
348     fun translate(matcher: ElementMatcher, edge: Edge, startsOutsideLayoutBounds: Boolean = true)
349 
350     /**
351      * Translate the element(s) matching [matcher] by the same amount that [anchor] is translated
352      * during this transition.
353      *
354      * Note: This currently only works if [anchor] is a shared element of this transition.
355      *
356      * TODO(b/290184746): Also support anchors that are not shared but translated because of other
357      *   transformations, like an edge translation.
358      */
359     fun anchoredTranslate(matcher: ElementMatcher, anchor: ElementKey)
360 
361     /**
362      * Scale the [width] and [height] of the element(s) matching [matcher]. Note that this scaling
363      * is done during layout, so it will potentially impact the size and position of other elements.
364      */
365     fun scaleSize(matcher: ElementMatcher, width: Float = 1f, height: Float = 1f)
366 
367     /**
368      * Scale the drawing with [scaleX] and [scaleY] of the element(s) matching [matcher]. Note this
369      * will only scale the draw inside of an element, therefore it won't impact layout of elements
370      * around it.
371      */
372     fun scaleDraw(
373         matcher: ElementMatcher,
374         scaleX: Float = 1f,
375         scaleY: Float = 1f,
376         pivot: Offset = Offset.Unspecified
377     )
378 
379     /**
380      * Scale the element(s) matching [matcher] so that it grows/shrinks to the same size as
381      * [anchor].
382      *
383      * Note: This currently only works if [anchor] is a shared element of this transition.
384      */
385     fun anchoredSize(
386         matcher: ElementMatcher,
387         anchor: ElementKey,
388         anchorWidth: Boolean = true,
389         anchorHeight: Boolean = true,
390     )
391 }
392