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.Easing
21 import androidx.compose.animation.core.LinearEasing
22 import androidx.compose.ui.geometry.Offset
23 import androidx.compose.ui.unit.Dp
24 import androidx.compose.ui.unit.dp
25 import com.android.compose.animation.scene.content.state.TransitionState
26 import com.android.compose.animation.scene.transformation.Transformation
27 import com.android.internal.jank.Cuj.CujType
28
29 /** Define the [transitions][SceneTransitions] to be used with a [SceneTransitionLayout]. */
transitionsnull30 fun transitions(builder: SceneTransitionsBuilder.() -> Unit): SceneTransitions {
31 return transitionsImpl(builder)
32 }
33
34 @DslMarker annotation class TransitionDsl
35
36 @TransitionDsl
37 interface SceneTransitionsBuilder {
38 /**
39 * The [InterruptionHandler] used when transitions are interrupted. Defaults to
40 * [DefaultInterruptionHandler].
41 */
42 var interruptionHandler: InterruptionHandler
43
44 /**
45 * Define the default animation to be played when transitioning [to] the specified content, from
46 * any content. For the animation specification to apply only when transitioning between two
47 * specific contents, use [from] instead.
48 *
49 * If [key] is not `null`, then this transition will only be used if the same key is specified
50 * when triggering the transition.
51 *
52 * Optionally, define a [preview] animation which will be played during the first stage of the
53 * transition, e.g. during the predictive back gesture. In case your transition should be
54 * reversible with the reverse animation having a preview as well, define a [reversePreview].
55 *
56 * @see from
57 */
tonull58 fun to(
59 to: ContentKey,
60 key: TransitionKey? = null,
61 @CujType cuj: Int? = null,
62 preview: (TransitionBuilder.() -> Unit)? = null,
63 reversePreview: (TransitionBuilder.() -> Unit)? = null,
64 builder: TransitionBuilder.() -> Unit = {},
65 )
66
67 /**
68 * Define the animation to be played when transitioning [from] the specified content. For the
69 * animation specification to apply only when transitioning between two specific contents, pass
70 * the destination content via the [to] argument.
71 *
72 * When looking up which transition should be used when animating from content A to content B,
73 * we pick the single transition with the given [key] and matching one of these predicates (in
74 * order of importance):
75 * 1. from == A && to == B
76 * 2. to == A && from == B, which is then treated in reverse.
77 * 3. (from == A && to == null) || (from == null && to == B)
78 * 4. (from == B && to == null) || (from == null && to == A), which is then treated in reverse.
79 *
80 * Optionally, define a [preview] animation which will be played during the first stage of the
81 * transition, e.g. during the predictive back gesture. In case your transition should be
82 * reversible with the reverse animation having a preview as well, define a [reversePreview].
83 */
fromnull84 fun from(
85 from: ContentKey,
86 to: ContentKey? = null,
87 key: TransitionKey? = null,
88 @CujType cuj: Int? = null,
89 preview: (TransitionBuilder.() -> Unit)? = null,
90 reversePreview: (TransitionBuilder.() -> Unit)? = null,
91 builder: TransitionBuilder.() -> Unit = {},
92 )
93 }
94
95 interface BaseTransitionBuilder : PropertyTransformationBuilder {
96 /**
97 * The distance it takes for this transition to animate from 0% to 100% when it is driven by a
98 * [UserAction].
99 *
100 * If `null`, a default distance will be used that depends on the [UserAction] performed.
101 */
102 var distance: UserActionDistance?
103
104 /**
105 * Define a progress-based range for the transformations inside [builder].
106 *
107 * For instance, the following will fade `Foo` during the first half of the transition then it
108 * will translate it by 100.dp during the second half.
109 *
110 * ```
111 * fractionRange(end = 0.5f) { fade(Foo) }
112 * fractionRange(start = 0.5f) { translate(Foo, x = 100.dp) }
113 * ```
114 *
115 * @param start the start of the range, in the [0; 1] range.
116 * @param end the end of the range, in the [0; 1] range.
117 */
fractionRangenull118 fun fractionRange(
119 start: Float? = null,
120 end: Float? = null,
121 easing: Easing = LinearEasing,
122 builder: PropertyTransformationBuilder.() -> Unit,
123 )
124 }
125
126 @TransitionDsl
127 interface TransitionBuilder : BaseTransitionBuilder {
128 /** The [TransitionState.Transition] for which we currently compute the transformations. */
129 val transition: TransitionState.Transition
130
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 /** The CUJ associated to this transitions. */
138 @CujType var cuj: Int?
139
140 /**
141 * Define a timestamp-based range for the transformations inside [builder].
142 *
143 * For instance, the following will fade `Foo` during the first half of the transition then it
144 * will translate it by 100.dp during the second half.
145 *
146 * ```
147 * spec = tween(500)
148 * timestampRange(end = 250) { fade(Foo) }
149 * timestampRange(start = 250) { translate(Foo, x = 100.dp) }
150 * ```
151 *
152 * Important: [spec] must be a [androidx.compose.animation.core.DurationBasedAnimationSpec] if
153 * you call [timestampRange], otherwise this will throw. The spec duration will be used to
154 * transform this range into a [fractionRange].
155 *
156 * @param startMillis the start of the range, in the [0; spec.duration] range.
157 * @param endMillis the end of the range, in the [0; spec.duration] range.
158 */
159 fun timestampRange(
160 startMillis: Int? = null,
161 endMillis: Int? = null,
162 easing: Easing = LinearEasing,
163 builder: PropertyTransformationBuilder.() -> Unit,
164 )
165
166 /**
167 * Configure the shared transition when [matcher] is shared between two scenes.
168 *
169 * @param enabled whether the matched element(s) should actually be shared in this transition.
170 * Defaults to true.
171 * @param elevateInContent the content in which we should elevate the element when it is shared,
172 * drawing above all other composables of that content. If `null` (the default), we will
173 * simply draw this element in its original location. If not `null`, it has to be either the
174 * [fromContent][TransitionState.Transition.fromContent] or
175 * [toContent][TransitionState.Transition.toContent] of the transition.
176 */
177 fun sharedElement(
178 matcher: ElementMatcher,
179 enabled: Boolean = true,
180 elevateInContent: ContentKey? = null,
181 )
182
183 /**
184 * Adds the transformations in [builder] but in reversed order. This allows you to partially
185 * reuse the definition of the transition from scene `Foo` to scene `Bar` inside the definition
186 * of the transition from scene `Bar` to scene `Foo`.
187 */
188 fun reversed(builder: TransitionBuilder.() -> Unit)
189 }
190
191 /**
192 * An interface to decide where we should draw shared Elements or compose MovableElements.
193 *
194 * @see DefaultElementContentPicker
195 * @see HighestZIndexContentPicker
196 * @see LowestZIndexContentPicker
197 * @see MovableElementContentPicker
198 */
199 interface ElementContentPicker {
200 /**
201 * Return the content in which [element] should be drawn (when using `Modifier.element(key)`) or
202 * composed (when using `MovableElement(key)`) during the given [transition].
203 *
204 * Important: For [MovableElements][ContentScope.MovableElement], this content picker will
205 * *always* be used during transitions to decide whether we should compose that element in a
206 * given content or not. Therefore, you should make sure that the returned [ContentKey] contains
207 * the movable element, otherwise that element will not be composed in any scene during the
208 * transition.
209 */
contentDuringTransitionnull210 fun contentDuringTransition(
211 element: ElementKey,
212 transition: TransitionState.Transition,
213 fromContentZIndex: Long,
214 toContentZIndex: Long,
215 ): ContentKey
216
217 /**
218 * Return [transition.fromContent] if it is in [contents] and [transition.toContent] is not, or
219 * return [transition.toContent] if it is in [contents] and [transition.fromContent] is not. If
220 * neither [transition.toContent] and [transition.fromContent] are in [contents] or if both
221 * [transition.fromContent] and [transition.toContent] are in [contents], throw an exception.
222 *
223 * This function can be useful when computing the content in which a movable element should be
224 * composed.
225 */
226 fun pickSingleContentIn(
227 contents: Set<ContentKey>,
228 transition: TransitionState.Transition,
229 element: ElementKey,
230 ): ContentKey {
231 val fromContent = transition.fromContent
232 val toContent = transition.toContent
233 val fromContentInContents = contents.contains(fromContent)
234 val toContentInContents = contents.contains(toContent)
235
236 if (fromContentInContents && toContentInContents) {
237 error(
238 "Element $element can be in both $fromContent and $toContent. You should add a " +
239 "special case for this transition before calling pickSingleSceneIn()."
240 )
241 }
242
243 if (!fromContentInContents && !toContentInContents) {
244 error(
245 "Element $element can be neither in $fromContent and $toContent. This either " +
246 "means that you should add one of them in the scenes set passed to " +
247 "pickSingleSceneIn(), or there is an internal error and this element was " +
248 "composed when it shouldn't be."
249 )
250 }
251
252 return if (fromContentInContents) {
253 fromContent
254 } else {
255 toContent
256 }
257 }
258 }
259
260 /**
261 * An element picker on which we can query the set of contents (scenes or overlays) that contain the
262 * element. This is needed by [MovableElement], that needs to know at composition time on which of
263 * the candidate contents an element should be composed.
264 *
265 * @see DefaultElementContentPicker(contents)
266 * @see HighestZIndexContentPicker(contents)
267 * @see LowestZIndexContentPicker(contents)
268 * @see MovableElementContentPicker
269 */
270 interface StaticElementContentPicker : ElementContentPicker {
271 /** The exhaustive lists of contents that contain this element. */
272 val contents: Set<ContentKey>
273 }
274
275 /**
276 * An [ElementContentPicker] that draws/composes elements in the content with the highest z-order.
277 */
278 object HighestZIndexContentPicker : ElementContentPicker {
contentDuringTransitionnull279 override fun contentDuringTransition(
280 element: ElementKey,
281 transition: TransitionState.Transition,
282 fromContentZIndex: Long,
283 toContentZIndex: Long,
284 ): ContentKey {
285 return if (fromContentZIndex > toContentZIndex) {
286 transition.fromContent
287 } else {
288 transition.toContent
289 }
290 }
291
292 /**
293 * Return a [StaticElementContentPicker] that behaves like [HighestZIndexContentPicker] and can
294 * be used by [MovableElement].
295 */
invokenull296 operator fun invoke(contents: Set<ContentKey>): StaticElementContentPicker {
297 return object : StaticElementContentPicker {
298 override val contents: Set<ContentKey> = contents
299
300 override fun contentDuringTransition(
301 element: ElementKey,
302 transition: TransitionState.Transition,
303 fromContentZIndex: Long,
304 toContentZIndex: Long,
305 ): ContentKey {
306 return HighestZIndexContentPicker.contentDuringTransition(
307 element,
308 transition,
309 fromContentZIndex,
310 toContentZIndex,
311 )
312 }
313 }
314 }
315 }
316
317 /**
318 * An [ElementContentPicker] that draws/composes elements in the content with the lowest z-order.
319 */
320 object LowestZIndexContentPicker : ElementContentPicker {
contentDuringTransitionnull321 override fun contentDuringTransition(
322 element: ElementKey,
323 transition: TransitionState.Transition,
324 fromContentZIndex: Long,
325 toContentZIndex: Long,
326 ): ContentKey {
327 return if (fromContentZIndex < toContentZIndex) {
328 transition.fromContent
329 } else {
330 transition.toContent
331 }
332 }
333
334 /**
335 * Return a [StaticElementContentPicker] that behaves like [LowestZIndexContentPicker] and can
336 * be used by [MovableElement].
337 */
invokenull338 operator fun invoke(contents: Set<ContentKey>): StaticElementContentPicker {
339 return object : StaticElementContentPicker {
340 override val contents: Set<ContentKey> = contents
341
342 override fun contentDuringTransition(
343 element: ElementKey,
344 transition: TransitionState.Transition,
345 fromContentZIndex: Long,
346 toContentZIndex: Long,
347 ): ContentKey {
348 return LowestZIndexContentPicker.contentDuringTransition(
349 element,
350 transition,
351 fromContentZIndex,
352 toContentZIndex,
353 )
354 }
355 }
356 }
357 }
358
359 /**
360 * An [ElementContentPicker] that draws/composes elements in the content we are transitioning to,
361 * iff that content is in [contents].
362 *
363 * This picker can be useful for movable elements whose content size depends on its content (because
364 * it wraps it) in at least one scene. That way, the target size of the MovableElement will be
365 * computed in the scene we are going to and, given that this element was probably already composed
366 * in the scene we are going from before starting the transition, the interpolated size of the
367 * movable element during the transition should be correct.
368 *
369 * The downside of this picker is that the zIndex of the element when going from scene A to scene B
370 * is not the same as when going from scene B to scene A, so it's not usable in situations where
371 * z-ordering during the transition matters.
372 */
373 class MovableElementContentPicker(override val contents: Set<ContentKey>) :
374 StaticElementContentPicker {
contentDuringTransitionnull375 override fun contentDuringTransition(
376 element: ElementKey,
377 transition: TransitionState.Transition,
378 fromContentZIndex: Long,
379 toContentZIndex: Long,
380 ): ContentKey {
381 return when {
382 transition.toContent in contents -> transition.toContent
383 else -> {
384 check(transition.fromContent in contents) {
385 "Neither ${transition.fromContent} nor ${transition.toContent} are in " +
386 "contents. This transition should not have been used for this element."
387 }
388 transition.fromContent
389 }
390 }
391 }
392 }
393
394 /** The default [ElementContentPicker]. */
395 val DefaultElementContentPicker = HighestZIndexContentPicker
396
397 /** The [DefaultElementContentPicker] that can be used for [MovableElement]s. */
DefaultElementContentPickernull398 fun DefaultElementContentPicker(contents: Set<ContentKey>): StaticElementContentPicker {
399 return HighestZIndexContentPicker(contents)
400 }
401
402 @TransitionDsl
403 interface PropertyTransformationBuilder {
404 /**
405 * Fade the element(s) matching [matcher]. This will automatically fade in or fade out if the
406 * element is entering or leaving the scene, respectively.
407 */
fadenull408 fun fade(matcher: ElementMatcher)
409
410 /** Translate the element(s) matching [matcher] by ([x], [y]) dp. */
411 fun translate(matcher: ElementMatcher, x: Dp = 0.dp, y: Dp = 0.dp)
412
413 /**
414 * Translate the element(s) matching [matcher] from/to the [edge] of the [SceneTransitionLayout]
415 * animating it.
416 *
417 * If [startsOutsideLayoutBounds] is `true`, then the element will start completely outside of
418 * the layout bounds (i.e. none of it will be visible at progress = 0f if the layout clips its
419 * content). If it is `false`, then the element will start aligned with the edge of the layout
420 * (i.e. it will be completely visible at progress = 0f).
421 */
422 fun translate(matcher: ElementMatcher, edge: Edge, startsOutsideLayoutBounds: Boolean = true)
423
424 /**
425 * Translate the element(s) matching [matcher] by the same amount that [anchor] is translated
426 * during this transition.
427 *
428 * Note: This currently only works if [anchor] is a shared element of this transition.
429 *
430 * TODO(b/290184746): Also support anchors that are not shared but translated because of other
431 * transformations, like an edge translation.
432 */
433 fun anchoredTranslate(matcher: ElementMatcher, anchor: ElementKey)
434
435 /**
436 * Scale the [width] and [height] of the element(s) matching [matcher]. Note that this scaling
437 * is done during layout, so it will potentially impact the size and position of other elements.
438 */
439 fun scaleSize(matcher: ElementMatcher, width: Float = 1f, height: Float = 1f)
440
441 /**
442 * Scale the drawing with [scaleX] and [scaleY] of the element(s) matching [matcher]. Note this
443 * will only scale the draw inside of an element, therefore it won't impact layout of elements
444 * around it.
445 */
446 fun scaleDraw(
447 matcher: ElementMatcher,
448 scaleX: Float = 1f,
449 scaleY: Float = 1f,
450 pivot: Offset = Offset.Unspecified,
451 )
452
453 /**
454 * Scale the element(s) matching [matcher] so that it grows/shrinks to the same size as
455 * [anchor].
456 *
457 * Note: This currently only works if [anchor] is a shared element of this transition.
458 */
459 fun anchoredSize(
460 matcher: ElementMatcher,
461 anchor: ElementKey,
462 anchorWidth: Boolean = true,
463 anchorHeight: Boolean = true,
464 )
465
466 /** Apply a [transformation] to the element(s) matching [matcher]. */
467 fun transformation(matcher: ElementMatcher, transformation: Transformation.Factory)
468 }
469