1 /*
<lambda>null2 * Copyright (C) 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.foundation.gestures.Orientation
20 import androidx.compose.ui.geometry.Offset
21 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
22 import androidx.compose.ui.unit.dp
23 import androidx.compose.ui.unit.round
24 import androidx.compose.ui.util.fastCoerceIn
25 import com.android.compose.animation.scene.content.Content
26 import com.android.compose.animation.scene.content.state.TransitionState.HasOverscrollProperties.Companion.DistanceUnspecified
27 import com.android.compose.nestedscroll.OnStopScope
28 import com.android.compose.nestedscroll.PriorityNestedScrollConnection
29 import com.android.compose.nestedscroll.ScrollController
30 import com.android.compose.ui.util.SpaceVectorConverter
31 import kotlin.math.absoluteValue
32 import kotlinx.coroutines.NonCancellable
33 import kotlinx.coroutines.launch
34 import kotlinx.coroutines.withContext
35
36 internal interface DraggableHandler {
37 /**
38 * Start a drag with the given [pointersDown] and [overSlop].
39 *
40 * The returned [DragController] should be used to continue or stop the drag.
41 */
42 fun onDragStarted(pointersDown: PointersInfo.PointersDown?, overSlop: Float): DragController
43 }
44
45 /**
46 * The [DragController] provides control over the transition between two scenes through the [onDrag]
47 * and [onStop] methods.
48 */
49 internal interface DragController {
50 /**
51 * Drag the current scene by [delta] pixels.
52 *
53 * @param delta The distance to drag the scene in pixels.
54 * @return the consumed [delta]
55 */
onDragnull56 fun onDrag(delta: Float): Float
57
58 /**
59 * Stop the current drag with the given [velocity].
60 *
61 * @param velocity The velocity of the drag when it stopped.
62 * @param canChangeContent Whether the content can be changed as a result of this drag.
63 * @return the consumed [velocity] when the animation complete
64 */
65 suspend fun onStop(velocity: Float, canChangeContent: Boolean): Float
66
67 /**
68 * Cancels the current drag.
69 *
70 * @param canChangeContent Whether the content can be changed as a result of this drag.
71 */
72 fun onCancel(canChangeContent: Boolean)
73 }
74
75 internal class DraggableHandlerImpl(
76 internal val layoutImpl: SceneTransitionLayoutImpl,
77 internal val orientation: Orientation,
78 ) : DraggableHandler {
79 internal val nestedScrollKey = Any()
80
81 /** The [DraggableHandler] can only have one active [DragController] at a time. */
82 private var dragController: DragControllerImpl? = null
83
84 internal val isDrivingTransition: Boolean
85 get() = dragController?.isDrivingTransition == true
86
87 /**
88 * The velocity threshold at which the intent of the user is to swipe up or down. It is the same
89 * as SwipeableV2Defaults.VelocityThreshold.
90 */
91 internal val velocityThreshold: Float
92 get() = with(layoutImpl.density) { 125.dp.toPx() }
93
94 /**
95 * The positional threshold at which the intent of the user is to swipe to the next scene. It is
96 * the same as SwipeableV2Defaults.PositionalThreshold.
97 */
98 internal val positionalThreshold
99 get() = with(layoutImpl.density) { 56.dp.toPx() }
100
101 override fun onDragStarted(
102 pointersDown: PointersInfo.PointersDown?,
103 overSlop: Float,
104 ): DragController {
105 check(overSlop != 0f)
106 val swipes = computeSwipes(pointersDown)
107 val fromContent = layoutImpl.contentForUserActions()
108
109 swipes.updateSwipesResults(fromContent)
110 val result =
111 swipes.findUserActionResult(overSlop)
112 // As we were unable to locate a valid target scene, the initial SwipeAnimation
113 // cannot be defined. Consequently, a simple NoOp Controller will be returned.
114 ?: return NoOpDragController
115
116 val swipeAnimation = createSwipeAnimation(swipes, result)
117 return updateDragController(swipes, swipeAnimation)
118 }
119
120 private fun updateDragController(
121 swipes: Swipes,
122 swipeAnimation: SwipeAnimation<*>,
123 ): DragControllerImpl {
124 val newDragController = DragControllerImpl(this, swipes, swipeAnimation)
125 newDragController.updateTransition(swipeAnimation, force = true)
126 dragController = newDragController
127 return newDragController
128 }
129
130 private fun createSwipeAnimation(swipes: Swipes, result: UserActionResult): SwipeAnimation<*> {
131 val upOrLeftResult = swipes.upOrLeftResult
132 val downOrRightResult = swipes.downOrRightResult
133 val isUpOrLeft =
134 when (result) {
135 upOrLeftResult -> true
136 downOrRightResult -> false
137 else -> error("Unknown result $result ($upOrLeftResult $downOrRightResult)")
138 }
139
140 return createSwipeAnimation(layoutImpl, result, isUpOrLeft, orientation)
141 }
142
143 private fun resolveSwipeSource(startedPosition: Offset): SwipeSource.Resolved? {
144 return layoutImpl.swipeSourceDetector.source(
145 layoutSize = layoutImpl.lastSize,
146 position = startedPosition.round(),
147 density = layoutImpl.density,
148 orientation = orientation,
149 )
150 }
151
152 private fun computeSwipes(pointersDown: PointersInfo.PointersDown?): Swipes {
153 val fromSource = pointersDown?.let { resolveSwipeSource(it.startedPosition) }
154 return Swipes(
155 upOrLeft = resolveSwipe(orientation, isUpOrLeft = true, pointersDown, fromSource),
156 downOrRight = resolveSwipe(orientation, isUpOrLeft = false, pointersDown, fromSource),
157 )
158 }
159 }
160
resolveSwipenull161 private fun resolveSwipe(
162 orientation: Orientation,
163 isUpOrLeft: Boolean,
164 pointersDown: PointersInfo.PointersDown?,
165 fromSource: SwipeSource.Resolved?,
166 ): Swipe.Resolved {
167 return Swipe.Resolved(
168 direction =
169 when (orientation) {
170 Orientation.Horizontal ->
171 if (isUpOrLeft) {
172 SwipeDirection.Resolved.Left
173 } else {
174 SwipeDirection.Resolved.Right
175 }
176
177 Orientation.Vertical ->
178 if (isUpOrLeft) {
179 SwipeDirection.Resolved.Up
180 } else {
181 SwipeDirection.Resolved.Down
182 }
183 },
184 // If the number of pointers is not specified, 1 is assumed.
185 pointerCount = pointersDown?.count ?: 1,
186 // Resolves the pointer type only if all pointers are of the same type.
187 pointersType = pointersDown?.countByType?.keys?.singleOrNull(),
188 fromSource = fromSource,
189 )
190 }
191
192 /** @param swipes The [Swipes] associated to the current gesture. */
193 private class DragControllerImpl(
194 private val draggableHandler: DraggableHandlerImpl,
195 val swipes: Swipes,
196 var swipeAnimation: SwipeAnimation<*>,
<lambda>null197 ) : DragController, SpaceVectorConverter by SpaceVectorConverter(draggableHandler.orientation) {
198 val layoutState = draggableHandler.layoutImpl.state
199
200 val overscrollableContent: OverscrollableContent =
201 when (draggableHandler.orientation) {
202 Orientation.Vertical -> draggableHandler.layoutImpl.verticalOverscrollableContent
203 Orientation.Horizontal -> draggableHandler.layoutImpl.horizontalOverscrollableContent
204 }
205
206 /**
207 * Whether this handle is active. If this returns false, calling [onDrag] and [onStop] will do
208 * nothing.
209 */
210 val isDrivingTransition: Boolean
211 get() = layoutState.transitionState == swipeAnimation.contentTransition
212
213 init {
214 check(!isDrivingTransition) { "Multiple controllers with the same SwipeTransition" }
215 }
216
217 fun updateTransition(newTransition: SwipeAnimation<*>, force: Boolean = false) {
218 if (force || isDrivingTransition) {
219 layoutState.startTransitionImmediately(
220 animationScope = draggableHandler.layoutImpl.animationScope,
221 newTransition.contentTransition,
222 true,
223 )
224 }
225
226 swipeAnimation = newTransition
227 }
228
229 /**
230 * We receive a [delta] that can be consumed to change the offset of the current
231 * [SwipeAnimation].
232 *
233 * @return the consumed delta
234 */
235 override fun onDrag(delta: Float): Float {
236 val initialAnimation = swipeAnimation
237 if (delta == 0f || !isDrivingTransition || initialAnimation.isAnimatingOffset()) {
238 return 0f
239 }
240 // swipeAnimation can change during the gesture, we want to always use the initial reference
241 // during the whole drag gesture.
242 return dragWithOverscroll(delta, animation = initialAnimation)
243 }
244
245 private fun <T : ContentKey> dragWithOverscroll(
246 delta: Float,
247 animation: SwipeAnimation<T>,
248 ): Float {
249 require(delta != 0f) { "delta should not be 0" }
250 var overscrollEffect = overscrollableContent.currentOverscrollEffect
251
252 // If we're already overscrolling, continue with the current effect for a smooth finish.
253 if (overscrollEffect == null || !overscrollEffect.isInProgress) {
254 // Otherwise, determine the target content (toContent or fromContent) for the new
255 // overscroll effect based on the gesture's direction.
256 val content = animation.contentByDirection(delta)
257 overscrollEffect = overscrollableContent.applyOverscrollEffectOn(content)
258 }
259
260 // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
261 if (!overscrollEffect.node.node.isAttached) {
262 return drag(delta, animation)
263 }
264
265 return overscrollEffect
266 .applyToScroll(
267 delta = delta.toOffset(),
268 source = NestedScrollSource.UserInput,
269 performScroll = {
270 val preScrollAvailable = it.toFloat()
271 drag(preScrollAvailable, animation).toOffset()
272 },
273 )
274 .toFloat()
275 }
276
277 private fun <T : ContentKey> drag(delta: Float, animation: SwipeAnimation<T>): Float {
278 if (delta == 0f) return 0f
279
280 val distance = animation.distance()
281 val previousOffset = animation.dragOffset
282 val desiredOffset = previousOffset + delta
283 val desiredProgress = animation.computeProgress(desiredOffset)
284
285 // Note: the distance could be negative if fromContent is above or to the left of toContent.
286 val newOffset =
287 when {
288 distance == DistanceUnspecified ||
289 animation.contentTransition.isWithinProgressRange(desiredProgress) ->
290 desiredOffset
291 distance > 0f -> desiredOffset.fastCoerceIn(0f, distance)
292 else -> desiredOffset.fastCoerceIn(distance, 0f)
293 }
294
295 animation.dragOffset = newOffset
296 return newOffset - previousOffset
297 }
298
299 override suspend fun onStop(velocity: Float, canChangeContent: Boolean): Float {
300 // To ensure that any ongoing animation completes gracefully and avoids an undefined state,
301 // we execute the actual `onStop` logic in a non-cancellable context. This prevents the
302 // coroutine from being cancelled prematurely, which could interrupt the animation.
303 // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
304 return withContext(NonCancellable) { onStop(velocity, canChangeContent, swipeAnimation) }
305 }
306
307 private suspend fun <T : ContentKey> onStop(
308 velocity: Float,
309 canChangeContent: Boolean,
310
311 // Important: Make sure that this has the same name as [this.swipeAnimation] so that all the
312 // code here references the current animation when [onDragStopped] is called, otherwise the
313 // callbacks (like onAnimationCompleted()) might incorrectly finish a new transition that
314 // replaced this one.
315 swipeAnimation: SwipeAnimation<T>,
316 ): Float {
317 // The state was changed since the drag started; don't do anything.
318 if (!isDrivingTransition || swipeAnimation.isAnimatingOffset()) {
319 return 0f
320 }
321
322 val fromContent = swipeAnimation.fromContent
323 val targetContent =
324 if (canChangeContent) {
325 // If we are halfway between two contents, we check what the target will be based on
326 // the velocity and offset of the transition, then we launch the animation.
327
328 val toContent = swipeAnimation.toContent
329
330 // Compute the destination content (and therefore offset) to settle in.
331 val offset = swipeAnimation.dragOffset
332 val distance = swipeAnimation.distance()
333 if (
334 distance != DistanceUnspecified &&
335 shouldCommitSwipe(
336 offset = offset,
337 distance = distance,
338 velocity = velocity,
339 wasCommitted = swipeAnimation.currentContent == toContent,
340 requiresFullDistanceSwipe = swipeAnimation.requiresFullDistanceSwipe,
341 )
342 ) {
343 toContent
344 } else {
345 fromContent
346 }
347 } else {
348 // We are doing an overscroll preview animation between scenes.
349 check(fromContent == swipeAnimation.currentContent) {
350 "canChangeContent is false but currentContent != fromContent"
351 }
352 fromContent
353 }
354
355 val overscrollEffect = overscrollableContent.applyOverscrollEffectOn(targetContent)
356
357 // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
358 if (!overscrollEffect.node.node.isAttached) {
359 return swipeAnimation.animateOffset(velocity, targetContent)
360 }
361
362 overscrollEffect.applyToFling(
363 velocity = velocity.toVelocity(),
364 performFling = {
365 val velocityLeft = it.toFloat()
366 swipeAnimation.animateOffset(velocityLeft, targetContent).toVelocity()
367 },
368 )
369
370 return velocity
371 }
372
373 /**
374 * Whether the swipe to the target scene should be committed or not. This is inspired by
375 * SwipeableV2.computeTarget().
376 */
377 private fun shouldCommitSwipe(
378 offset: Float,
379 distance: Float,
380 velocity: Float,
381 wasCommitted: Boolean,
382 requiresFullDistanceSwipe: Boolean,
383 ): Boolean {
384 if (requiresFullDistanceSwipe && !wasCommitted) {
385 return offset / distance >= 1f
386 }
387
388 fun isCloserToTarget(): Boolean {
389 return (offset - distance).absoluteValue < offset.absoluteValue
390 }
391
392 val velocityThreshold = draggableHandler.velocityThreshold
393 val positionalThreshold = draggableHandler.positionalThreshold
394
395 // Swiping up or left.
396 if (distance < 0f) {
397 return if (offset > 0f || velocity >= velocityThreshold) {
398 false
399 } else {
400 velocity <= -velocityThreshold ||
401 (offset <= -positionalThreshold && !wasCommitted) ||
402 isCloserToTarget()
403 }
404 }
405
406 // Swiping down or right.
407 return if (offset < 0f || velocity <= -velocityThreshold) {
408 false
409 } else {
410 velocity >= velocityThreshold ||
411 (offset >= positionalThreshold && !wasCommitted) ||
412 isCloserToTarget()
413 }
414 }
415
416 override fun onCancel(canChangeContent: Boolean) {
417 swipeAnimation.contentTransition.coroutineScope.launch {
418 onStop(velocity = 0f, canChangeContent = canChangeContent)
419 }
420 }
421 }
422
423 /** The [Swipe] associated to a given fromScene, startedPosition and pointersDown. */
424 internal class Swipes(val upOrLeft: Swipe.Resolved, val downOrRight: Swipe.Resolved) {
425 /** The [UserActionResult] associated to up and down swipes. */
426 var upOrLeftResult: UserActionResult? = null
427 var downOrRightResult: UserActionResult? = null
428
computeSwipesResultsnull429 private fun computeSwipesResults(
430 fromContent: Content
431 ): Pair<UserActionResult?, UserActionResult?> {
432 val upOrLeftResult = fromContent.findActionResultBestMatch(swipe = upOrLeft)
433 val downOrRightResult = fromContent.findActionResultBestMatch(swipe = downOrRight)
434 return upOrLeftResult to downOrRightResult
435 }
436
437 /**
438 * Update the swipes results.
439 *
440 * Usually we don't want to update them while doing a drag, because this could change the target
441 * content (jump cutting) to a different content, when some system state changed the targets the
442 * background. However, an update is needed any time we calculate the targets for a new
443 * fromContent.
444 */
updateSwipesResultsnull445 fun updateSwipesResults(fromContent: Content) {
446 val (upOrLeftResult, downOrRightResult) = computeSwipesResults(fromContent)
447
448 this.upOrLeftResult = upOrLeftResult
449 this.downOrRightResult = downOrRightResult
450 }
451
452 /**
453 * Returns the [UserActionResult] in the direction of [directionOffset].
454 *
455 * @param directionOffset signed float that indicates the direction. Positive is down or right
456 * negative is up or left.
457 * @return null when there are no targets in either direction. If one direction is null and you
458 * drag into the null direction this function will return the opposite direction, assuming
459 * that the users intention is to start the drag into the other direction eventually. If
460 * [directionOffset] is 0f and both direction are available, it will default to
461 * [upOrLeftResult].
462 */
findUserActionResultnull463 fun findUserActionResult(directionOffset: Float): UserActionResult? {
464 return when {
465 upOrLeftResult == null && downOrRightResult == null -> null
466 (directionOffset < 0f && upOrLeftResult != null) || downOrRightResult == null ->
467 upOrLeftResult
468
469 else -> downOrRightResult
470 }
471 }
472 }
473
474 internal class NestedScrollHandlerImpl(
475 private val draggableHandler: DraggableHandlerImpl,
476 internal var topOrLeftBehavior: NestedScrollBehavior,
477 internal var bottomOrRightBehavior: NestedScrollBehavior,
478 internal var isExternalOverscrollGesture: () -> Boolean,
479 private val pointersInfoOwner: PointersInfoOwner,
480 ) {
481 val connection: PriorityNestedScrollConnection = nestedScrollConnection()
482
nestedScrollConnectionnull483 private fun nestedScrollConnection(): PriorityNestedScrollConnection {
484 // If we performed a long gesture before entering priority mode, we would have to avoid
485 // moving on to the next scene.
486 var canChangeScene = false
487
488 var lastPointersDown: PointersInfo.PointersDown? = null
489
490 fun shouldEnableSwipes(): Boolean {
491 return draggableHandler.layoutImpl
492 .contentForUserActions()
493 .shouldEnableSwipes(draggableHandler.orientation)
494 }
495
496 return PriorityNestedScrollConnection(
497 orientation = draggableHandler.orientation,
498 canStartPreScroll = { _, _, _ -> false },
499 canStartPostScroll = { offsetAvailable, offsetBeforeStart, _ ->
500 val behavior: NestedScrollBehavior =
501 when {
502 offsetAvailable > 0f -> topOrLeftBehavior
503 offsetAvailable < 0f -> bottomOrRightBehavior
504 else -> return@PriorityNestedScrollConnection false
505 }
506
507 val isZeroOffset =
508 if (isExternalOverscrollGesture()) false else offsetBeforeStart == 0f
509
510 val pointersDown: PointersInfo.PointersDown? =
511 when (val info = pointersInfoOwner.pointersInfo()) {
512 PointersInfo.MouseWheel -> {
513 // Do not support mouse wheel interactions
514 return@PriorityNestedScrollConnection false
515 }
516
517 is PointersInfo.PointersDown -> info
518 null -> null
519 }
520 lastPointersDown = pointersDown
521
522 when (behavior) {
523 NestedScrollBehavior.EdgeNoPreview -> {
524 canChangeScene = isZeroOffset
525 isZeroOffset && shouldEnableSwipes()
526 }
527
528 NestedScrollBehavior.EdgeWithPreview -> {
529 canChangeScene = isZeroOffset
530 shouldEnableSwipes()
531 }
532
533 NestedScrollBehavior.EdgeAlways -> {
534 canChangeScene = true
535 shouldEnableSwipes()
536 }
537 }
538 },
539 canStartPostFling = { velocityAvailable ->
540 val behavior: NestedScrollBehavior =
541 when {
542 velocityAvailable > 0f -> topOrLeftBehavior
543 velocityAvailable < 0f -> bottomOrRightBehavior
544 else -> return@PriorityNestedScrollConnection false
545 }
546
547 // We could start an overscroll animation
548 canChangeScene = false
549
550 val pointersDown: PointersInfo.PointersDown? =
551 when (val info = pointersInfoOwner.pointersInfo()) {
552 PointersInfo.MouseWheel -> {
553 // Do not support mouse wheel interactions
554 return@PriorityNestedScrollConnection false
555 }
556
557 is PointersInfo.PointersDown -> info
558 null -> null
559 }
560 lastPointersDown = pointersDown
561
562 behavior.canStartOnPostFling && shouldEnableSwipes()
563 },
564 onStart = { firstScroll ->
565 scrollController(
566 dragController =
567 draggableHandler.onDragStarted(
568 pointersDown = lastPointersDown,
569 overSlop = firstScroll,
570 ),
571 canChangeScene = canChangeScene,
572 pointersInfoOwner = pointersInfoOwner,
573 )
574 },
575 )
576 }
577 }
578
scrollControllernull579 private fun scrollController(
580 dragController: DragController,
581 canChangeScene: Boolean,
582 pointersInfoOwner: PointersInfoOwner,
583 ): ScrollController {
584 return object : ScrollController {
585 override fun onScroll(deltaScroll: Float, source: NestedScrollSource): Float {
586 if (pointersInfoOwner.pointersInfo() == PointersInfo.MouseWheel) {
587 // Do not support mouse wheel interactions
588 return 0f
589 }
590
591 return dragController.onDrag(delta = deltaScroll)
592 }
593
594 override suspend fun OnStopScope.onStop(initialVelocity: Float): Float {
595 return dragController.onStop(
596 velocity = initialVelocity,
597 canChangeContent = canChangeScene,
598 )
599 }
600
601 override fun onCancel() {
602 dragController.onCancel(canChangeScene)
603 }
604
605 /**
606 * We need to maintain scroll priority even if the scene transition can no longer consume
607 * the scroll gesture to allow us to return to the previous scene.
608 */
609 override fun canCancelScroll(available: Float, consumed: Float) = false
610
611 override fun canStopOnPreFling() = true
612 }
613 }
614
615 /**
616 * The number of pixels below which there won't be a visible difference in the transition and from
617 * which the animation can stop.
618 */
619 // TODO(b/290184746): Have a better default visibility threshold which takes the swipe distance into
620 // account instead.
621 internal const val OffsetVisibilityThreshold = 0.5f
622
623 private object NoOpDragController : DragController {
onDragnull624 override fun onDrag(delta: Float) = 0f
625
626 override suspend fun onStop(velocity: Float, canChangeContent: Boolean) = 0f
627
628 override fun onCancel(canChangeContent: Boolean) {
629 /* do nothing */
630 }
631 }
632