1 /*
<lambda>null2  * 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.foundation.gestures.Orientation
20 import androidx.compose.runtime.Stable
21 import androidx.compose.ui.Modifier
22 import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
23 import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode
24 import androidx.compose.ui.input.pointer.PointerEvent
25 import androidx.compose.ui.input.pointer.PointerEventPass
26 import androidx.compose.ui.node.DelegatableNode
27 import androidx.compose.ui.node.DelegatingNode
28 import androidx.compose.ui.node.ModifierNodeElement
29 import androidx.compose.ui.node.PointerInputModifierNode
30 import androidx.compose.ui.node.TraversableNode
31 import androidx.compose.ui.node.findNearestAncestor
32 import androidx.compose.ui.unit.IntSize
33 import com.android.compose.animation.scene.content.Content
34 
35 /**
36  * Configures the swipeable behavior of a [SceneTransitionLayout] depending on the current state.
37  */
38 @Stable
39 internal fun Modifier.swipeToScene(
40     draggableHandler: DraggableHandlerImpl,
41     swipeDetector: SwipeDetector,
42 ): Modifier {
43     return if (draggableHandler.enabled()) {
44         this.then(SwipeToSceneElement(draggableHandler, swipeDetector))
45     } else {
46         this
47     }
48 }
49 
DraggableHandlerImplnull50 private fun DraggableHandlerImpl.enabled(): Boolean {
51     return isDrivingTransition || contentForSwipes().shouldEnableSwipes(orientation)
52 }
53 
contentForSwipesnull54 private fun DraggableHandlerImpl.contentForSwipes(): Content {
55     return layoutImpl.contentForUserActions()
56 }
57 
58 /** Whether swipe should be enabled in the given [orientation]. */
shouldEnableSwipesnull59 internal fun Content.shouldEnableSwipes(orientation: Orientation): Boolean {
60     if (userActions.isEmpty()) {
61         return false
62     }
63 
64     return userActions.keys.any { it is Swipe.Resolved && it.direction.orientation == orientation }
65 }
66 
67 /**
68  * Finds the best matching [UserActionResult] for the given [swipe] within this [Content].
69  * Prioritizes actions with matching [Swipe.Resolved.fromSource].
70  *
71  * @param swipe The swipe to match against.
72  * @return The best matching [UserActionResult], or `null` if no match is found.
73  */
findActionResultBestMatchnull74 internal fun Content.findActionResultBestMatch(swipe: Swipe.Resolved): UserActionResult? {
75     var bestPoints = Int.MIN_VALUE
76     var bestMatch: UserActionResult? = null
77     userActions.forEach { (actionSwipe, actionResult) ->
78         if (
79             actionSwipe !is Swipe.Resolved ||
80                 // The direction must match.
81                 actionSwipe.direction != swipe.direction ||
82                 // The number of pointers down must match.
83                 actionSwipe.pointerCount != swipe.pointerCount ||
84                 // The action requires a specific fromSource.
85                 (actionSwipe.fromSource != null && actionSwipe.fromSource != swipe.fromSource) ||
86                 // The action requires a specific pointerType.
87                 (actionSwipe.pointersType != null && actionSwipe.pointersType != swipe.pointersType)
88         ) {
89             // This action is not eligible.
90             return@forEach
91         }
92 
93         val sameFromSource = actionSwipe.fromSource == swipe.fromSource
94         val samePointerType = actionSwipe.pointersType == swipe.pointersType
95         // Prioritize actions with a perfect match.
96         if (sameFromSource && samePointerType) {
97             return actionResult
98         }
99 
100         var points = 0
101         if (sameFromSource) points++
102         if (samePointerType) points++
103 
104         // Otherwise, keep track of the best eligible action.
105         if (points > bestPoints) {
106             bestPoints = points
107             bestMatch = actionResult
108         }
109     }
110     return bestMatch
111 }
112 
113 private data class SwipeToSceneElement(
114     val draggableHandler: DraggableHandlerImpl,
115     val swipeDetector: SwipeDetector,
116 ) : ModifierNodeElement<SwipeToSceneRootNode>() {
createnull117     override fun create(): SwipeToSceneRootNode =
118         SwipeToSceneRootNode(draggableHandler, swipeDetector)
119 
120     override fun update(node: SwipeToSceneRootNode) {
121         node.update(draggableHandler, swipeDetector)
122     }
123 }
124 
125 private class SwipeToSceneRootNode(
126     draggableHandler: DraggableHandlerImpl,
127     swipeDetector: SwipeDetector,
128 ) : DelegatingNode() {
129     private var delegateNode = delegate(SwipeToSceneNode(draggableHandler, swipeDetector))
130 
updatenull131     fun update(draggableHandler: DraggableHandlerImpl, swipeDetector: SwipeDetector) {
132         if (draggableHandler == delegateNode.draggableHandler) {
133             // Simple update, just update the swipe detector directly and keep the node.
134             delegateNode.swipeDetector = swipeDetector
135         } else {
136             // The draggableHandler changed, force recreate the underlying SwipeToSceneNode.
137             undelegate(delegateNode)
138             delegateNode = delegate(SwipeToSceneNode(draggableHandler, swipeDetector))
139         }
140     }
141 }
142 
143 private class SwipeToSceneNode(
144     val draggableHandler: DraggableHandlerImpl,
145     swipeDetector: SwipeDetector,
146 ) : DelegatingNode(), PointerInputModifierNode {
147     private val dispatcher = NestedScrollDispatcher()
148     private val multiPointerDraggableNode =
149         delegate(
150             MultiPointerDraggableNode(
151                 orientation = draggableHandler.orientation,
152                 onDragStarted = draggableHandler::onDragStarted,
153                 onFirstPointerDown = ::onFirstPointerDown,
154                 swipeDetector = swipeDetector,
155                 dispatcher = dispatcher,
156             )
157         )
158 
159     var swipeDetector: SwipeDetector
160         get() = multiPointerDraggableNode.swipeDetector
161         set(value) {
162             multiPointerDraggableNode.swipeDetector = value
163         }
164 
165     private val nestedScrollHandlerImpl =
166         NestedScrollHandlerImpl(
167             draggableHandler = draggableHandler,
168             topOrLeftBehavior = NestedScrollBehavior.Default,
169             bottomOrRightBehavior = NestedScrollBehavior.Default,
<lambda>null170             isExternalOverscrollGesture = { false },
<lambda>null171             pointersInfoOwner = { multiPointerDraggableNode.pointersInfo() },
172         )
173 
174     init {
175         delegate(nestedScrollModifierNode(nestedScrollHandlerImpl.connection, dispatcher))
176         delegate(ScrollBehaviorOwnerNode(draggableHandler.nestedScrollKey, nestedScrollHandlerImpl))
177     }
178 
onFirstPointerDownnull179     private fun onFirstPointerDown() {
180         // When we drag our finger across the screen, the NestedScrollConnection keeps track of all
181         // the scroll events until we lift our finger. However, in some cases, the connection might
182         // not receive the "up" event. This can lead to an incorrect initial state for the gesture.
183         // To prevent this issue, we can call the reset() method when the first finger touches the
184         // screen. This ensures that the NestedScrollConnection starts from a correct state.
185         nestedScrollHandlerImpl.connection.reset()
186     }
187 
onDetachnull188     override fun onDetach() {
189         // Make sure we reset the scroll connection when this modifier is removed from composition
190         nestedScrollHandlerImpl.connection.reset()
191     }
192 
onPointerEventnull193     override fun onPointerEvent(
194         pointerEvent: PointerEvent,
195         pass: PointerEventPass,
196         bounds: IntSize,
197     ) = multiPointerDraggableNode.onPointerEvent(pointerEvent, pass, bounds)
198 
199     override fun onCancelPointerInput() = multiPointerDraggableNode.onCancelPointerInput()
200 }
201 
202 /** Find the [ScrollBehaviorOwner] for the current orientation. */
203 internal fun DelegatableNode.findScrollBehaviorOwner(
204     draggableHandler: DraggableHandlerImpl
205 ): ScrollBehaviorOwner? {
206     // If there are no scenes in a particular orientation, the corresponding ScrollBehaviorOwnerNode
207     // is removed from the composition.
208     return findNearestAncestor(draggableHandler.nestedScrollKey) as? ScrollBehaviorOwner
209 }
210 
211 internal fun interface ScrollBehaviorOwner {
updateScrollBehaviorsnull212     fun updateScrollBehaviors(
213         topOrLeftBehavior: NestedScrollBehavior,
214         bottomOrRightBehavior: NestedScrollBehavior,
215         isExternalOverscrollGesture: () -> Boolean,
216     )
217 }
218 
219 /**
220  * We need a node that receives the desired behavior.
221  *
222  * TODO(b/353234530) move this logic into [SwipeToSceneNode]
223  */
224 private class ScrollBehaviorOwnerNode(
225     override val traverseKey: Any,
226     val nestedScrollHandlerImpl: NestedScrollHandlerImpl,
227 ) : Modifier.Node(), TraversableNode, ScrollBehaviorOwner {
228     override fun updateScrollBehaviors(
229         topOrLeftBehavior: NestedScrollBehavior,
230         bottomOrRightBehavior: NestedScrollBehavior,
231         isExternalOverscrollGesture: () -> Boolean,
232     ) {
233         nestedScrollHandlerImpl.topOrLeftBehavior = topOrLeftBehavior
234         nestedScrollHandlerImpl.bottomOrRightBehavior = bottomOrRightBehavior
235         nestedScrollHandlerImpl.isExternalOverscrollGesture = isExternalOverscrollGesture
236     }
237 }
238