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