1 /*
2 * Copyright (C) 2024 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.Animatable
20 import androidx.compose.animation.core.AnimationSpec
21 import androidx.compose.animation.core.AnimationVector1D
22 import androidx.compose.foundation.gestures.Orientation
23 import androidx.compose.runtime.getValue
24 import androidx.compose.runtime.mutableFloatStateOf
25 import androidx.compose.runtime.mutableStateOf
26 import androidx.compose.runtime.setValue
27 import com.android.compose.animation.scene.content.state.TransitionState
28 import com.android.compose.animation.scene.content.state.TransitionState.HasOverscrollProperties.Companion.DistanceUnspecified
29 import kotlin.math.absoluteValue
30 import kotlinx.coroutines.CompletableDeferred
31 import kotlinx.coroutines.launch
32
createSwipeAnimationnull33 internal fun createSwipeAnimation(
34 layoutState: MutableSceneTransitionLayoutStateImpl,
35 result: UserActionResult,
36 isUpOrLeft: Boolean,
37 orientation: Orientation,
38 distance: Float,
39 ): SwipeAnimation<*> {
40 return createSwipeAnimation(
41 layoutState,
42 result,
43 isUpOrLeft,
44 orientation,
45 distance = { distance },
46 contentForUserActions = {
47 error("Computing contentForUserActions requires a SceneTransitionLayoutImpl")
48 },
49 )
50 }
51
createSwipeAnimationnull52 internal fun createSwipeAnimation(
53 layoutImpl: SceneTransitionLayoutImpl,
54 result: UserActionResult,
55 isUpOrLeft: Boolean,
56 orientation: Orientation,
57 distance: Float = DistanceUnspecified,
58 ): SwipeAnimation<*> {
59 var lastDistance = distance
60
61 fun distance(animation: SwipeAnimation<*>): Float {
62 if (lastDistance != DistanceUnspecified) {
63 return lastDistance
64 }
65
66 val absoluteDistance =
67 with(animation.contentTransition.transformationSpec.distance ?: DefaultSwipeDistance) {
68 layoutImpl.userActionDistanceScope.absoluteDistance(
69 fromContent = animation.fromContent,
70 toContent = animation.toContent,
71 orientation = orientation,
72 )
73 }
74
75 if (absoluteDistance <= 0f) {
76 return DistanceUnspecified
77 }
78
79 val distance = if (isUpOrLeft) -absoluteDistance else absoluteDistance
80 lastDistance = distance
81 return distance
82 }
83
84 return createSwipeAnimation(
85 layoutImpl.state,
86 result,
87 isUpOrLeft,
88 orientation,
89 distance = ::distance,
90 contentForUserActions = { layoutImpl.contentForUserActions().key },
91 )
92 }
93
createSwipeAnimationnull94 private fun createSwipeAnimation(
95 layoutState: MutableSceneTransitionLayoutStateImpl,
96 result: UserActionResult,
97 isUpOrLeft: Boolean,
98 orientation: Orientation,
99 distance: (SwipeAnimation<*>) -> Float,
100 contentForUserActions: () -> ContentKey,
101 ): SwipeAnimation<*> {
102 fun <T : ContentKey> swipeAnimation(fromContent: T, toContent: T): SwipeAnimation<T> {
103 return SwipeAnimation(
104 layoutState = layoutState,
105 fromContent = fromContent,
106 toContent = toContent,
107 orientation = orientation,
108 isUpOrLeft = isUpOrLeft,
109 requiresFullDistanceSwipe = result.requiresFullDistanceSwipe,
110 distance = distance,
111 )
112 }
113
114 return when (result) {
115 is UserActionResult.ChangeScene -> {
116 val fromScene = layoutState.currentScene
117 val toScene = result.toScene
118 ChangeSceneSwipeTransition(
119 layoutState = layoutState,
120 swipeAnimation = swipeAnimation(fromContent = fromScene, toContent = toScene),
121 key = result.transitionKey,
122 replacedTransition = null,
123 )
124 .swipeAnimation
125 }
126 is UserActionResult.ShowOverlay -> {
127 val fromScene = layoutState.currentScene
128 val overlay = result.overlay
129 ShowOrHideOverlaySwipeTransition(
130 layoutState = layoutState,
131 fromOrToScene = fromScene,
132 overlay = overlay,
133 swipeAnimation = swipeAnimation(fromContent = fromScene, toContent = overlay),
134 key = result.transitionKey,
135 replacedTransition = null,
136 )
137 .swipeAnimation
138 }
139 is UserActionResult.HideOverlay -> {
140 val toScene = layoutState.currentScene
141 val overlay = result.overlay
142 ShowOrHideOverlaySwipeTransition(
143 layoutState = layoutState,
144 fromOrToScene = toScene,
145 overlay = overlay,
146 swipeAnimation = swipeAnimation(fromContent = overlay, toContent = toScene),
147 key = result.transitionKey,
148 replacedTransition = null,
149 )
150 .swipeAnimation
151 }
152 is UserActionResult.ReplaceByOverlay -> {
153 val fromOverlay =
154 when (val contentForUserActions = contentForUserActions()) {
155 is SceneKey ->
156 error("ReplaceByOverlay can only be called when an overlay is shown")
157 is OverlayKey -> contentForUserActions
158 }
159
160 val toOverlay = result.overlay
161 ReplaceOverlaySwipeTransition(
162 layoutState = layoutState,
163 swipeAnimation =
164 swipeAnimation(fromContent = fromOverlay, toContent = toOverlay),
165 key = result.transitionKey,
166 replacedTransition = null,
167 )
168 .swipeAnimation
169 }
170 }
171 }
172
createSwipeAnimationnull173 internal fun createSwipeAnimation(old: SwipeAnimation<*>): SwipeAnimation<*> {
174 return when (val transition = old.contentTransition) {
175 is TransitionState.Transition.ChangeScene -> {
176 ChangeSceneSwipeTransition(transition as ChangeSceneSwipeTransition).swipeAnimation
177 }
178 is TransitionState.Transition.ShowOrHideOverlay -> {
179 ShowOrHideOverlaySwipeTransition(transition as ShowOrHideOverlaySwipeTransition)
180 .swipeAnimation
181 }
182 is TransitionState.Transition.ReplaceOverlay -> {
183 ReplaceOverlaySwipeTransition(transition as ReplaceOverlaySwipeTransition)
184 .swipeAnimation
185 }
186 }
187 }
188
189 /** A helper class that contains the main logic for swipe transitions. */
190 internal class SwipeAnimation<T : ContentKey>(
191 val layoutState: MutableSceneTransitionLayoutStateImpl,
192 val fromContent: T,
193 val toContent: T,
194 override val orientation: Orientation,
195 override val isUpOrLeft: Boolean,
196 val requiresFullDistanceSwipe: Boolean,
197 private val distance: (SwipeAnimation<T>) -> Float,
198 currentContent: T = fromContent,
199 dragOffset: Float = 0f,
200 ) : TransitionState.HasOverscrollProperties {
201 /** The [TransitionState.Transition] whose implementation delegates to this [SwipeAnimation]. */
202 lateinit var contentTransition: TransitionState.Transition
203
204 private var _currentContent by mutableStateOf(currentContent)
205 var currentContent: T
206 get() = _currentContent
207 set(value) {
<lambda>null208 check(!isAnimatingOffset()) {
209 "currentContent can not be changed once we are animating the offset"
210 }
211 _currentContent = value
212 }
213
214 val progress: Float
215 get() {
216 // Important: If we are going to return early because distance is equal to 0, we should
217 // still make sure we read the offset before returning so that the calling code still
218 // subscribes to the offset value.
219 val animatable = offsetAnimation
220 val offset =
221 when {
222 isInPreviewStage -> 0f
223 animatable != null -> animatable.value
224 else -> dragOffset
225 }
226
227 return computeProgress(offset)
228 }
229
computeProgressnull230 fun computeProgress(offset: Float): Float {
231 val distance = distance()
232 if (distance == DistanceUnspecified) {
233 return 0f
234 }
235 return offset / distance
236 }
237
238 val progressVelocity: Float
239 get() {
240 val animatable = offsetAnimation ?: return 0f
241 val distance = distance()
242 if (distance == DistanceUnspecified) {
243 return 0f
244 }
245
246 val velocityInDistanceUnit = animatable.velocity
247 return velocityInDistanceUnit / distance.absoluteValue
248 }
249
250 val previewProgress: Float
251 get() {
252 val offset =
253 if (isInPreviewStage) {
254 offsetAnimation?.value ?: dragOffset
255 } else {
256 dragOffset
257 }
258 return computeProgress(offset)
259 }
260
261 val previewProgressVelocity: Float
262 get() = 0f
263
264 val isInPreviewStage: Boolean
265 get() = contentTransition.previewTransformationSpec != null && currentContent == fromContent
266
267 override var bouncingContent: ContentKey? = null
268
269 /** The current offset caused by the drag gesture. */
270 var dragOffset by mutableFloatStateOf(dragOffset)
271
272 /** The offset animation that animates the offset once the user lifts their finger. */
273 private var offsetAnimation: Animatable<Float, AnimationVector1D>? by mutableStateOf(null)
274 private val offsetAnimationRunnable = CompletableDeferred<(suspend () -> Unit)?>()
275
276 val isUserInputOngoing: Boolean
277 get() = offsetAnimation == null
278
279 override val absoluteDistance: Float
280 get() = distance().absoluteValue
281
282 constructor(
283 other: SwipeAnimation<T>
284 ) : this(
285 layoutState = other.layoutState,
286 fromContent = other.fromContent,
287 toContent = other.toContent,
288 orientation = other.orientation,
289 isUpOrLeft = other.isUpOrLeft,
290 requiresFullDistanceSwipe = other.requiresFullDistanceSwipe,
291 distance = other.distance,
292 currentContent = other.currentContent,
293 dragOffset = other.offsetAnimation?.value ?: other.dragOffset,
294 )
295
runnull296 suspend fun run() {
297 // This animation will first be driven by finger, then when the user lift their finger we
298 // start an animation to the target offset (progress = 1f or progress = 0f). We await() for
299 // offsetAnimationRunnable to be completed and then run it.
300 val runAnimation = offsetAnimationRunnable.await() ?: return
301 runAnimation()
302 }
303
304 /**
305 * The signed distance between [fromContent] and [toContent]. It is negative if [fromContent] is
306 * above or to the left of [toContent].
307 *
308 * Note that this distance can be equal to [DistanceUnspecified] during the first frame of a
309 * transition when the distance depends on the size or position of an element that is composed
310 * in the content we are going to.
311 */
distancenull312 fun distance(): Float = distance(this)
313
314 fun isAnimatingOffset(): Boolean = offsetAnimation != null
315
316 /** Get the [ContentKey] ([fromContent] or [toContent]) associated to the current [direction] */
317 fun contentByDirection(direction: Float): T {
318 require(direction != 0f) { "Cannot find a content in this direction: $direction" }
319 val isDirectionToContent = (isUpOrLeft && direction < 0) || (!isUpOrLeft && direction > 0)
320 return if (isDirectionToContent) {
321 toContent
322 } else {
323 fromContent
324 }
325 }
326
327 /**
328 * Animate the offset to a [targetContent], using the [initialVelocity] and an optional [spec]
329 *
330 * @return the velocity consumed
331 */
animateOffsetnull332 suspend fun animateOffset(
333 initialVelocity: Float,
334 targetContent: T,
335 spec: AnimationSpec<Float>? = null,
336 ): Float {
337 check(!isAnimatingOffset()) { "SwipeAnimation.animateOffset() can only be called once" }
338
339 val initialProgress = progress
340 // Skip the animation if we have already reached the target content and the overscroll does
341 // not animate anything.
342 val hasReachedTargetContent =
343 (targetContent == toContent && initialProgress >= 1f) ||
344 (targetContent == fromContent && initialProgress <= 0f)
345 val skipAnimation =
346 hasReachedTargetContent && !contentTransition.isWithinProgressRange(initialProgress)
347
348 val targetContent =
349 if (targetContent != currentContent && !canChangeContent(targetContent)) {
350 currentContent
351 } else {
352 targetContent
353 }
354
355 val targetOffset =
356 if (targetContent == fromContent) {
357 0f
358 } else {
359 val distance = distance()
360 check(distance != DistanceUnspecified) {
361 "distance is equal to $DistanceUnspecified"
362 }
363 distance
364 }
365
366 // If the effective current content changed, it should be reflected right now in the
367 // current state, even before the settle animation is ongoing. That way all the
368 // swipeables and back handlers will be refreshed and the user can for instance quickly
369 // swipe vertically from A => B then horizontally from B => C, or swipe from A => B then
370 // immediately go back B => A.
371 if (targetContent != currentContent) {
372 currentContent = targetContent
373 }
374
375 val initialOffset =
376 if (contentTransition.previewTransformationSpec != null && targetContent == toContent) {
377 0f
378 } else {
379 dragOffset
380 }
381
382 val animatable =
383 Animatable(initialOffset, OffsetVisibilityThreshold).also { offsetAnimation = it }
384
385 check(isAnimatingOffset())
386
387 // Note: we still create the animatable and set it on offsetAnimation even when
388 // skipAnimation is true, just so that isUserInputOngoing and isAnimatingOffset() are
389 // unchanged even despite this small skip-optimization (which is just an implementation
390 // detail).
391 if (skipAnimation) {
392 // Unblock the job.
393 offsetAnimationRunnable.complete(null)
394 return 0f
395 }
396
397 val isTargetGreater = targetOffset > animatable.value
398 val startedWhenOvercrollingTargetContent =
399 if (targetContent == fromContent) initialProgress < 0f else initialProgress > 1f
400
401 val swipeSpec =
402 spec
403 ?: contentTransition.transformationSpec.swipeSpec
404 ?: layoutState.transitions.defaultSwipeSpec
405
406 val velocityConsumed = CompletableDeferred<Float>()
407
408 offsetAnimationRunnable.complete {
409 try {
410 animatable.animateTo(
411 targetValue = targetOffset,
412 animationSpec = swipeSpec,
413 initialVelocity = initialVelocity,
414 ) {
415 if (bouncingContent == null) {
416 val isBouncing =
417 if (isTargetGreater) {
418 if (startedWhenOvercrollingTargetContent) {
419 value >= targetOffset
420 } else {
421 value > targetOffset
422 }
423 } else {
424 if (startedWhenOvercrollingTargetContent) {
425 value <= targetOffset
426 } else {
427 value < targetOffset
428 }
429 }
430
431 if (isBouncing) {
432 bouncingContent = targetContent
433
434 // Immediately stop this transition if we are bouncing on a content that
435 // does not bounce.
436 if (!contentTransition.isWithinProgressRange(progress)) {
437 // We are no longer able to consume the velocity, the rest can be
438 // consumed by another component in the hierarchy.
439 velocityConsumed.complete(initialVelocity - velocity)
440 throw SnapException()
441 }
442 }
443 }
444 }
445 } catch (_: SnapException) {
446 /* Ignore. */
447 } finally {
448 if (!velocityConsumed.isCompleted) {
449 // The animation consumed the whole available velocity
450 velocityConsumed.complete(initialVelocity)
451 }
452 }
453 }
454
455 return velocityConsumed.await()
456 }
457
458 /** An exception thrown during the animation to stop it immediately. */
459 private class SnapException : Exception()
460
canChangeContentnull461 private fun canChangeContent(targetContent: ContentKey): Boolean {
462 return when (val transition = contentTransition) {
463 is TransitionState.Transition.ChangeScene ->
464 layoutState.canChangeScene(targetContent as SceneKey)
465 is TransitionState.Transition.ShowOrHideOverlay -> {
466 if (targetContent == transition.overlay) {
467 layoutState.canShowOverlay(transition.overlay)
468 } else {
469 layoutState.canHideOverlay(transition.overlay)
470 }
471 }
472 is TransitionState.Transition.ReplaceOverlay -> {
473 val to = targetContent as OverlayKey
474 val from =
475 if (to == transition.toOverlay) transition.fromOverlay else transition.toOverlay
476 layoutState.canReplaceOverlay(from, to)
477 }
478 }
479 }
480
freezeAndAnimateToCurrentStatenull481 fun freezeAndAnimateToCurrentState() {
482 if (isAnimatingOffset()) return
483
484 contentTransition.coroutineScope.launch {
485 animateOffset(initialVelocity = 0f, targetContent = currentContent)
486 }
487 }
488 }
489
490 private object DefaultSwipeDistance : UserActionDistance {
absoluteDistancenull491 override fun UserActionDistanceScope.absoluteDistance(
492 fromContent: ContentKey,
493 toContent: ContentKey,
494 orientation: Orientation,
495 ): Float {
496 val fromContentSize = checkNotNull(fromContent.targetSize())
497 return when (orientation) {
498 Orientation.Horizontal -> fromContentSize.width
499 Orientation.Vertical -> fromContentSize.height
500 }.toFloat()
501 }
502 }
503
504 private class ChangeSceneSwipeTransition(
505 val layoutState: MutableSceneTransitionLayoutStateImpl,
506 val swipeAnimation: SwipeAnimation<SceneKey>,
507 override val key: TransitionKey?,
508 replacedTransition: ChangeSceneSwipeTransition?,
509 ) :
510 TransitionState.Transition.ChangeScene(
511 swipeAnimation.fromContent,
512 swipeAnimation.toContent,
513 replacedTransition,
514 ),
515 TransitionState.HasOverscrollProperties by swipeAnimation {
516
517 constructor(
518 other: ChangeSceneSwipeTransition
519 ) : this(
520 layoutState = other.layoutState,
521 swipeAnimation = SwipeAnimation(other.swipeAnimation),
522 key = other.key,
523 replacedTransition = other,
524 )
525
526 init {
527 swipeAnimation.contentTransition = this
528 }
529
530 override val currentScene: SceneKey
531 get() = swipeAnimation.currentContent
532
533 override val progress: Float
534 get() = swipeAnimation.progress
535
536 override val progressVelocity: Float
537 get() = swipeAnimation.progressVelocity
538
539 override val previewProgress: Float
540 get() = swipeAnimation.previewProgress
541
542 override val previewProgressVelocity: Float
543 get() = swipeAnimation.previewProgressVelocity
544
545 override val isInPreviewStage: Boolean
546 get() = swipeAnimation.isInPreviewStage
547
548 override val isInitiatedByUserInput: Boolean = true
549
550 override val isUserInputOngoing: Boolean
551 get() = swipeAnimation.isUserInputOngoing
552
runnull553 override suspend fun run() {
554 swipeAnimation.run()
555 }
556
freezeAndAnimateToCurrentStatenull557 override fun freezeAndAnimateToCurrentState() {
558 swipeAnimation.freezeAndAnimateToCurrentState()
559 }
560 }
561
562 private class ShowOrHideOverlaySwipeTransition(
563 val layoutState: MutableSceneTransitionLayoutStateImpl,
564 val swipeAnimation: SwipeAnimation<ContentKey>,
565 overlay: OverlayKey,
566 fromOrToScene: SceneKey,
567 override val key: TransitionKey?,
568 replacedTransition: ShowOrHideOverlaySwipeTransition?,
569 ) :
570 TransitionState.Transition.ShowOrHideOverlay(
571 overlay,
572 fromOrToScene,
573 swipeAnimation.fromContent,
574 swipeAnimation.toContent,
575 replacedTransition,
576 ),
577 TransitionState.HasOverscrollProperties by swipeAnimation {
578 constructor(
579 other: ShowOrHideOverlaySwipeTransition
580 ) : this(
581 layoutState = other.layoutState,
582 swipeAnimation = SwipeAnimation(other.swipeAnimation),
583 overlay = other.overlay,
584 fromOrToScene = other.fromOrToScene,
585 key = other.key,
586 replacedTransition = other,
587 )
588
589 init {
590 swipeAnimation.contentTransition = this
591 }
592
593 override val isEffectivelyShown: Boolean
594 get() = swipeAnimation.currentContent == overlay
595
596 override val progress: Float
597 get() = swipeAnimation.progress
598
599 override val progressVelocity: Float
600 get() = swipeAnimation.progressVelocity
601
602 override val previewProgress: Float
603 get() = swipeAnimation.previewProgress
604
605 override val previewProgressVelocity: Float
606 get() = swipeAnimation.previewProgressVelocity
607
608 override val isInPreviewStage: Boolean
609 get() = swipeAnimation.isInPreviewStage
610
611 override val isInitiatedByUserInput: Boolean = true
612
613 override val isUserInputOngoing: Boolean
614 get() = swipeAnimation.isUserInputOngoing
615
runnull616 override suspend fun run() {
617 swipeAnimation.run()
618 }
619
freezeAndAnimateToCurrentStatenull620 override fun freezeAndAnimateToCurrentState() {
621 swipeAnimation.freezeAndAnimateToCurrentState()
622 }
623 }
624
625 private class ReplaceOverlaySwipeTransition(
626 val layoutState: MutableSceneTransitionLayoutStateImpl,
627 val swipeAnimation: SwipeAnimation<OverlayKey>,
628 override val key: TransitionKey?,
629 replacedTransition: ReplaceOverlaySwipeTransition?,
630 ) :
631 TransitionState.Transition.ReplaceOverlay(
632 swipeAnimation.fromContent,
633 swipeAnimation.toContent,
634 replacedTransition,
635 ),
636 TransitionState.HasOverscrollProperties by swipeAnimation {
637 constructor(
638 other: ReplaceOverlaySwipeTransition
639 ) : this(
640 layoutState = other.layoutState,
641 swipeAnimation = SwipeAnimation(other.swipeAnimation),
642 key = other.key,
643 replacedTransition = other,
644 )
645
646 init {
647 swipeAnimation.contentTransition = this
648 }
649
650 override val effectivelyShownOverlay: OverlayKey
651 get() = swipeAnimation.currentContent
652
653 override val progress: Float
654 get() = swipeAnimation.progress
655
656 override val progressVelocity: Float
657 get() = swipeAnimation.progressVelocity
658
659 override val previewProgress: Float
660 get() = swipeAnimation.previewProgress
661
662 override val previewProgressVelocity: Float
663 get() = swipeAnimation.previewProgressVelocity
664
665 override val isInPreviewStage: Boolean
666 get() = swipeAnimation.isInPreviewStage
667
668 override val isInitiatedByUserInput: Boolean = true
669
670 override val isUserInputOngoing: Boolean
671 get() = swipeAnimation.isUserInputOngoing
672
runnull673 override suspend fun run() {
674 swipeAnimation.run()
675 }
676
freezeAndAnimateToCurrentStatenull677 override fun freezeAndAnimateToCurrentState() {
678 swipeAnimation.freezeAndAnimateToCurrentState()
679 }
680 }
681