• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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