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.OverscrollEffect
20 import androidx.compose.foundation.gestures.Orientation
21 import androidx.compose.ui.geometry.Offset
22 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
23 import androidx.compose.ui.input.pointer.PointerInputChange
24 import androidx.compose.ui.input.pointer.PointerType
25 import androidx.compose.ui.unit.Velocity
26 import androidx.compose.ui.unit.dp
27 import androidx.compose.ui.unit.round
28 import androidx.compose.ui.util.fastCoerceIn
29 import com.android.compose.animation.scene.content.Content
30 import com.android.compose.animation.scene.content.state.TransitionState.Companion.DistanceUnspecified
31 import com.android.compose.animation.scene.effect.GestureEffect
32 import com.android.compose.gesture.NestedDraggable
33 import com.android.compose.ui.util.SpaceVectorConverter
34 import com.android.mechanics.DistanceGestureContext
35 import com.android.mechanics.spec.InputDirection
36 import kotlin.math.absoluteValue
37 import kotlinx.coroutines.launch
38
39 internal class DraggableHandler(
40 internal val layoutImpl: SceneTransitionLayoutImpl,
41 internal val orientation: Orientation,
42 private val gestureEffectProvider: (ContentKey) -> GestureEffect,
43 ) : NestedDraggable {
44 /** The [DraggableHandler] can only have one active [DragController] at a time. */
45 private var dragController: DragControllerImpl? = null
46
47 internal val isDrivingTransition: Boolean
48 get() = dragController?.isDrivingTransition == true
49
50 /**
51 * The velocity threshold at which the intent of the user is to swipe up or down. It is the same
52 * as SwipeableV2Defaults.VelocityThreshold.
53 */
54 internal val velocityThreshold: Float
55 get() = with(layoutImpl.density) { 125.dp.toPx() }
56
57 /**
58 * The positional threshold at which the intent of the user is to swipe to the next scene. It is
59 * the same as SwipeableV2Defaults.PositionalThreshold.
60 */
61 internal val positionalThreshold
62 get() = with(layoutImpl.density) { 56.dp.toPx() }
63
64 /** The [OverscrollEffect] that should consume any overscroll on this draggable. */
65 internal val overscrollEffect: OverscrollEffect = DelegatingOverscrollEffect()
66
67 override fun shouldStartDrag(change: PointerInputChange): Boolean {
68 return layoutImpl.swipeDetector.detectSwipe(change)
69 }
70
71 override fun shouldConsumeNestedPostScroll(sign: Float): Boolean {
72 return this.enabled()
73 }
74
75 override fun onDragStarted(
76 position: Offset,
77 sign: Float,
78 pointersDown: Int,
79 pointerType: PointerType?,
80 ): NestedDraggable.Controller {
81 check(sign != 0f)
82 val swipes = computeSwipes(position, pointersDown, pointerType)
83 val fromContent = layoutImpl.contentForUserActions()
84
85 swipes.updateSwipesResults(fromContent)
86 val upOrLeft = swipes.upOrLeftResult
87 val downOrRight = swipes.downOrRightResult
88 val result =
89 when {
90 sign < 0 -> upOrLeft ?: downOrRight
91 sign >= 0f -> downOrRight ?: upOrLeft
92 else -> null
93 } ?: return NoOpDragController
94
95 if (result is UserActionResult.ShowOverlay) {
96 layoutImpl.hideOverlays(result.hideCurrentOverlays)
97 }
98
99 val swipeAnimation = createSwipeAnimation(swipes, result)
100 return updateDragController(swipes, swipeAnimation)
101 }
102
103 private fun updateDragController(
104 swipes: Swipes,
105 swipeAnimation: SwipeAnimation<*>,
106 ): DragControllerImpl {
107 val newDragController = DragControllerImpl(this, swipes, swipeAnimation)
108 newDragController.updateTransition(swipeAnimation, force = true)
109 dragController = newDragController
110 return newDragController
111 }
112
113 private fun createSwipeAnimation(swipes: Swipes, result: UserActionResult): SwipeAnimation<*> {
114 val upOrLeftResult = swipes.upOrLeftResult
115 val downOrRightResult = swipes.downOrRightResult
116 val isUpOrLeft =
117 when (result) {
118 upOrLeftResult -> true
119 downOrRightResult -> false
120 else -> error("Unknown result $result ($upOrLeftResult $downOrRightResult)")
121 }
122
123 val gestureContext =
124 DistanceGestureContext(
125 initialDragOffset = 0f,
126 initialDirection = if (isUpOrLeft) InputDirection.Min else InputDirection.Max,
127 directionChangeSlop = layoutImpl.directionChangeSlop,
128 )
129
130 return createSwipeAnimation(
131 layoutImpl,
132 result,
133 isUpOrLeft,
134 orientation,
135 gestureContext,
136 layoutImpl.decayAnimationSpec,
137 )
138 }
139
140 private fun resolveSwipeSource(startedPosition: Offset): SwipeSource.Resolved? {
141 return layoutImpl.swipeSourceDetector.source(
142 layoutSize = layoutImpl.lastSize,
143 position = startedPosition.round(),
144 density = layoutImpl.density,
145 orientation = orientation,
146 )
147 }
148
149 private fun computeSwipes(
150 position: Offset,
151 pointersDown: Int,
152 pointerType: PointerType?,
153 ): Swipes {
154 val fromSource = resolveSwipeSource(position)
155 return Swipes(
156 upOrLeft =
157 resolveSwipe(orientation, isUpOrLeft = true, fromSource, pointersDown, pointerType),
158 downOrRight =
159 resolveSwipe(orientation, isUpOrLeft = false, fromSource, pointersDown, pointerType),
160 )
161 }
162
163 /**
164 * An implementation of [OverscrollEffect] that delegates to the correct content effect
165 * depending on the current scene/overlays and transition.
166 */
167 private inner class DelegatingOverscrollEffect :
168 OverscrollEffect, SpaceVectorConverter by SpaceVectorConverter(orientation) {
169 private var currentContent: ContentKey? = null
170 private var currentDelegate: GestureEffect? = null
171 set(value) {
172 field?.let { delegate ->
173 if (delegate.isInProgress) {
174 layoutImpl.animationScope.launch { delegate.ensureApplyToFlingIsCalled() }
175 }
176 }
177
178 field = value
179 }
180
181 override val isInProgress: Boolean
182 get() = currentDelegate?.isInProgress ?: false
183
184 override fun applyToScroll(
185 delta: Offset,
186 source: NestedScrollSource,
187 performScroll: (Offset) -> Offset,
188 ): Offset {
189 val available = delta.toFloat()
190 if (available == 0f) {
191 return performScroll(delta)
192 }
193
194 ensureDelegateIsNotNull(available)
195 val delegate = checkNotNull(currentDelegate)
196 return if (delegate.node.node.isAttached) {
197 delegate.applyToScroll(delta, source, performScroll)
198 } else {
199 performScroll(delta)
200 }
201 }
202
203 override suspend fun applyToFling(
204 velocity: Velocity,
205 performFling: suspend (Velocity) -> Velocity,
206 ) {
207 val available = velocity.toFloat()
208 if (available != 0f && isDrivingTransition) {
209 ensureDelegateIsNotNull(available)
210 }
211
212 // Note: we set currentDelegate and currentContent to null before calling performFling,
213 // which can suspend and take a lot of time.
214 val delegate = currentDelegate
215 currentDelegate = null
216 currentContent = null
217
218 if (delegate != null && delegate.node.node.isAttached) {
219 delegate.applyToFling(velocity, performFling)
220 } else {
221 performFling(velocity)
222 }
223 }
224
225 private fun ensureDelegateIsNotNull(direction: Float) {
226 require(direction != 0f)
227 if (isInProgress) {
228 return
229 }
230
231 val content =
232 if (isDrivingTransition) {
233 checkNotNull(dragController).swipeAnimation.contentByDirection(direction)
234 } else {
235 layoutImpl.contentForUserActions().key
236 }
237
238 if (content != currentContent) {
239 currentContent = content
240 currentDelegate = gestureEffectProvider(content)
241 }
242 }
243 }
244 }
245
resolveSwipenull246 private fun resolveSwipe(
247 orientation: Orientation,
248 isUpOrLeft: Boolean,
249 fromSource: SwipeSource.Resolved?,
250 pointersDown: Int,
251 pointerType: PointerType?,
252 ): Swipe.Resolved {
253 return Swipe.Resolved(
254 direction =
255 when (orientation) {
256 Orientation.Horizontal ->
257 if (isUpOrLeft) {
258 SwipeDirection.Resolved.Left
259 } else {
260 SwipeDirection.Resolved.Right
261 }
262
263 Orientation.Vertical ->
264 if (isUpOrLeft) {
265 SwipeDirection.Resolved.Up
266 } else {
267 SwipeDirection.Resolved.Down
268 }
269 },
270 pointerCount = pointersDown,
271 pointerType = pointerType,
272 fromSource = fromSource,
273 )
274 }
275
276 /** @param swipes The [Swipes] associated to the current gesture. */
277 private class DragControllerImpl(
278 private val draggableHandler: DraggableHandler,
279 val swipes: Swipes,
280 var swipeAnimation: SwipeAnimation<*>,
281 ) :
282 NestedDraggable.Controller,
<lambda>null283 SpaceVectorConverter by SpaceVectorConverter(draggableHandler.orientation) {
284 val layoutState = draggableHandler.layoutImpl.state
285
286 /**
287 * Whether this handle is active. If this returns false, calling [onDrag] and [onStop] will do
288 * nothing.
289 */
290 val isDrivingTransition: Boolean
291 get() = layoutState.transitionState == swipeAnimation.contentTransition
292
293 override val isReadyToDrag: Boolean
294 get() {
295 return !layoutState.deferTransitionProgress ||
296 with(draggableHandler.layoutImpl.elementStateScope) {
297 swipeAnimation.fromContent.targetSize() != null &&
298 swipeAnimation.toContent.targetSize() != null
299 }
300 }
301
302 init {
303 check(!isDrivingTransition) { "Multiple controllers with the same SwipeTransition" }
304 }
305
306 fun updateTransition(newTransition: SwipeAnimation<*>, force: Boolean = false) {
307 if (force || isDrivingTransition) {
308 layoutState.startTransitionImmediately(
309 animationScope = draggableHandler.layoutImpl.animationScope,
310 newTransition.contentTransition,
311 true,
312 )
313 }
314
315 swipeAnimation = newTransition
316 }
317
318 /**
319 * We receive a [delta] that can be consumed to change the offset of the current
320 * [SwipeAnimation].
321 *
322 * @return the consumed delta
323 */
324 override fun onDrag(delta: Float): Float {
325 val initialAnimation = swipeAnimation
326 if (delta == 0f || !isDrivingTransition || initialAnimation.isAnimatingOffset()) {
327 return 0f
328 }
329
330 // swipeAnimation can change during the gesture, we want to always use the initial reference
331 // during the whole drag gesture.
332 return drag(delta, animation = initialAnimation)
333 }
334
335 private fun <T : ContentKey> drag(delta: Float, animation: SwipeAnimation<T>): Float {
336 val distance = animation.distance()
337 val previousOffset = animation.dragOffset
338 val desiredOffset = previousOffset + delta
339
340 // Note: the distance could be negative if fromContent is above or to the left of toContent.
341 val newOffset =
342 when {
343 distance == DistanceUnspecified -> {
344 // Consume everything so that we don't overscroll, this will be coerced later
345 // when the distance is defined.
346 delta
347 }
348
349 distance > 0f -> desiredOffset.fastCoerceIn(0f, distance)
350 else -> desiredOffset.fastCoerceIn(distance, 0f)
351 }
352
353 animation.dragOffset = newOffset
354 return newOffset - previousOffset
355 }
356
357 override suspend fun onDragStopped(velocity: Float, awaitFling: suspend () -> Unit): Float {
358 return onStop(velocity, swipeAnimation, awaitFling)
359 }
360
361 private suspend fun <T : ContentKey> onStop(
362 velocity: Float,
363
364 // Important: Make sure that this has the same name as [this.swipeAnimation] so that all the
365 // code here references the current animation when [onDragStopped] is called, otherwise the
366 // callbacks (like onAnimationCompleted()) might incorrectly finish a new transition that
367 // replaced this one.
368 swipeAnimation: SwipeAnimation<T>,
369 awaitFling: suspend () -> Unit,
370 ): Float {
371 // The state was changed since the drag started; don't do anything.
372 if (!isDrivingTransition || swipeAnimation.isAnimatingOffset()) {
373 return 0f
374 }
375
376 val fromContent = swipeAnimation.fromContent
377 // If we are halfway between two contents, we check what the target will be based on
378 // the velocity and offset of the transition, then we launch the animation.
379
380 val toContent = swipeAnimation.toContent
381
382 // Compute the destination content (and therefore offset) to settle in.
383 val offset = swipeAnimation.dragOffset
384 val distance = swipeAnimation.distance()
385 val targetContent =
386 if (
387 distance != DistanceUnspecified &&
388 shouldCommitSwipe(
389 offset = offset,
390 distance = distance,
391 velocity = velocity,
392 wasCommitted = swipeAnimation.currentContent == toContent,
393 requiresFullDistanceSwipe = swipeAnimation.requiresFullDistanceSwipe,
394 )
395 ) {
396 toContent
397 } else {
398 fromContent
399 }
400
401 return swipeAnimation.animateOffset(velocity, targetContent, awaitFling = awaitFling)
402 }
403
404 /**
405 * Whether the swipe to the target scene should be committed or not. This is inspired by
406 * SwipeableV2.computeTarget().
407 */
408 private fun shouldCommitSwipe(
409 offset: Float,
410 distance: Float,
411 velocity: Float,
412 wasCommitted: Boolean,
413 requiresFullDistanceSwipe: Boolean,
414 ): Boolean {
415 if (requiresFullDistanceSwipe && !wasCommitted) {
416 return offset / distance >= 1f
417 }
418
419 fun isCloserToTarget(): Boolean {
420 return (offset - distance).absoluteValue < offset.absoluteValue
421 }
422
423 val velocityThreshold = draggableHandler.velocityThreshold
424 val positionalThreshold = draggableHandler.positionalThreshold
425
426 // Swiping up or left.
427 if (distance < 0f) {
428 return if (offset > 0f || velocity >= velocityThreshold) {
429 false
430 } else {
431 velocity <= -velocityThreshold ||
432 (offset <= -positionalThreshold && !wasCommitted) ||
433 isCloserToTarget()
434 }
435 }
436
437 // Swiping down or right.
438 return if (offset < 0f || velocity <= -velocityThreshold) {
439 false
440 } else {
441 velocity >= velocityThreshold ||
442 (offset >= positionalThreshold && !wasCommitted) ||
443 isCloserToTarget()
444 }
445 }
446 }
447
448 /** The [Swipe] associated to a given fromScene, startedPosition and pointersDown. */
449 internal class Swipes(val upOrLeft: Swipe.Resolved, val downOrRight: Swipe.Resolved) {
450 /** The [UserActionResult] associated to up and down swipes. */
451 var upOrLeftResult: UserActionResult? = null
452 var downOrRightResult: UserActionResult? = null
453
computeSwipesResultsnull454 private fun computeSwipesResults(
455 fromContent: Content
456 ): Pair<UserActionResult?, UserActionResult?> {
457 val upOrLeftResult = fromContent.findActionResultBestMatch(swipe = upOrLeft)
458 val downOrRightResult = fromContent.findActionResultBestMatch(swipe = downOrRight)
459 return upOrLeftResult to downOrRightResult
460 }
461
462 /**
463 * Finds the best matching [UserActionResult] for the given [swipe] within this [Content].
464 * Prioritizes actions with matching [Swipe.Resolved.fromSource].
465 *
466 * @param swipe The swipe to match against.
467 * @return The best matching [UserActionResult], or `null` if no match is found.
468 */
findActionResultBestMatchnull469 private fun Content.findActionResultBestMatch(swipe: Swipe.Resolved): UserActionResult? {
470 var bestPoints = Int.MIN_VALUE
471 var bestMatch: UserActionResult? = null
472 userActions.forEach { (actionSwipe, actionResult) ->
473 if (
474 actionSwipe !is Swipe.Resolved ||
475 // The direction must match.
476 actionSwipe.direction != swipe.direction ||
477 // The number of pointers down must match.
478 actionSwipe.pointerCount != swipe.pointerCount ||
479 // The action requires a specific fromSource.
480 (actionSwipe.fromSource != null &&
481 actionSwipe.fromSource != swipe.fromSource) ||
482 // The action requires a specific pointerType.
483 (actionSwipe.pointerType != null &&
484 actionSwipe.pointerType != swipe.pointerType)
485 ) {
486 // This action is not eligible.
487 return@forEach
488 }
489
490 val sameFromSource = actionSwipe.fromSource == swipe.fromSource
491 val samePointerType = actionSwipe.pointerType == swipe.pointerType
492 // Prioritize actions with a perfect match.
493 if (sameFromSource && samePointerType) {
494 return actionResult
495 }
496
497 var points = 0
498 if (sameFromSource) points++
499 if (samePointerType) points++
500
501 // Otherwise, keep track of the best eligible action.
502 if (points > bestPoints) {
503 bestPoints = points
504 bestMatch = actionResult
505 }
506 }
507 return bestMatch
508 }
509
510 /**
511 * Update the swipes results.
512 *
513 * Usually we don't want to update them while doing a drag, because this could change the target
514 * content (jump cutting) to a different content, when some system state changed the targets the
515 * background. However, an update is needed any time we calculate the targets for a new
516 * fromContent.
517 */
updateSwipesResultsnull518 fun updateSwipesResults(fromContent: Content) {
519 val (upOrLeftResult, downOrRightResult) = computeSwipesResults(fromContent)
520
521 this.upOrLeftResult = upOrLeftResult
522 this.downOrRightResult = downOrRightResult
523 }
524 }
525
526 /**
527 * The number of pixels below which there won't be a visible difference in the transition and from
528 * which the animation can stop.
529 */
530 // TODO(b/290184746): Have a better default visibility threshold which takes the swipe distance into
531 // account instead.
532 internal const val OffsetVisibilityThreshold = 0.5f
533
534 private object NoOpDragController : NestedDraggable.Controller {
onDragnull535 override fun onDrag(delta: Float) = 0f
536
537 override suspend fun onDragStopped(velocity: Float, awaitFling: suspend () -> Unit): Float = 0f
538 }
539