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