1 /*
2  * 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.ui.Modifier
20 import androidx.compose.ui.geometry.Offset
21 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
22 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
23 import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode
24 import androidx.compose.ui.node.DelegatingNode
25 import androidx.compose.ui.node.ModifierNodeElement
26 import androidx.compose.ui.platform.InspectorInfo
27 
28 /**
29  * Defines the behavior of the [SceneTransitionLayout] when a scrollable component is scrolled.
30  *
31  * By default, scrollable elements within the scene have priority during the user's gesture and are
32  * not consumed by the [SceneTransitionLayout] unless specifically requested via
33  * [nestedScrollToScene].
34  */
35 enum class NestedScrollBehavior(val canStartOnPostFling: Boolean) {
36     /**
37      * Overscroll will only be used by the [SceneTransitionLayout] to move to the next scene if the
38      * gesture begins at the edge of the scrollable component (so that a scroll in that direction
39      * can no longer be consumed). If the gesture is partially consumed by the scrollable component,
40      * there will be NO preview of the next scene.
41      *
42      * In addition, during scene transitions, scroll events are consumed by the
43      * [SceneTransitionLayout] instead of the scrollable component.
44      */
45     EdgeNoPreview(canStartOnPostFling = false),
46 
47     /**
48      * Overscroll will only be used by the [SceneTransitionLayout] to move to the next scene if the
49      * gesture begins at the edge of the scrollable component. If the gesture is partially consumed
50      * by the scrollable component, there will be a preview of the next scene.
51      *
52      * In addition, during scene transitions, scroll events are consumed by the
53      * [SceneTransitionLayout] instead of the scrollable component.
54      */
55     EdgeWithPreview(canStartOnPostFling = true),
56 
57     /**
58      * Any overscroll will be used by the [SceneTransitionLayout] to move to the next scene.
59      *
60      * In addition, during scene transitions, scroll events are consumed by the
61      * [SceneTransitionLayout] instead of the scrollable component.
62      */
63     EdgeAlways(canStartOnPostFling = true);
64 
65     companion object {
66         val Default = EdgeNoPreview
67     }
68 }
69 
nestedScrollToScenenull70 internal fun Modifier.nestedScrollToScene(
71     draggableHandler: DraggableHandlerImpl,
72     topOrLeftBehavior: NestedScrollBehavior,
73     bottomOrRightBehavior: NestedScrollBehavior,
74     isExternalOverscrollGesture: () -> Boolean,
75 ) =
76     this then
77         NestedScrollToSceneElement(
78             draggableHandler = draggableHandler,
79             topOrLeftBehavior = topOrLeftBehavior,
80             bottomOrRightBehavior = bottomOrRightBehavior,
81             isExternalOverscrollGesture = isExternalOverscrollGesture,
82         )
83 
84 private data class NestedScrollToSceneElement(
85     private val draggableHandler: DraggableHandlerImpl,
86     private val topOrLeftBehavior: NestedScrollBehavior,
87     private val bottomOrRightBehavior: NestedScrollBehavior,
88     private val isExternalOverscrollGesture: () -> Boolean,
89 ) : ModifierNodeElement<NestedScrollToSceneNode>() {
90     override fun create() =
91         NestedScrollToSceneNode(
92             draggableHandler = draggableHandler,
93             topOrLeftBehavior = topOrLeftBehavior,
94             bottomOrRightBehavior = bottomOrRightBehavior,
95             isExternalOverscrollGesture = isExternalOverscrollGesture,
96         )
97 
98     override fun update(node: NestedScrollToSceneNode) {
99         node.update(
100             draggableHandler = draggableHandler,
101             topOrLeftBehavior = topOrLeftBehavior,
102             bottomOrRightBehavior = bottomOrRightBehavior,
103             isExternalOverscrollGesture = isExternalOverscrollGesture,
104         )
105     }
106 
107     override fun InspectorInfo.inspectableProperties() {
108         name = "nestedScrollToScene"
109         properties["draggableHandler"] = draggableHandler
110         properties["topOrLeftBehavior"] = topOrLeftBehavior
111         properties["bottomOrRightBehavior"] = bottomOrRightBehavior
112     }
113 }
114 
115 private class NestedScrollToSceneNode(
116     private var draggableHandler: DraggableHandlerImpl,
117     private var topOrLeftBehavior: NestedScrollBehavior,
118     private var bottomOrRightBehavior: NestedScrollBehavior,
119     private var isExternalOverscrollGesture: () -> Boolean,
120 ) : DelegatingNode() {
121     private var scrollBehaviorOwner: ScrollBehaviorOwner? = null
122 
findScrollBehaviorOwnernull123     private fun findScrollBehaviorOwner(): ScrollBehaviorOwner? {
124         return scrollBehaviorOwner
125             ?: findScrollBehaviorOwner(draggableHandler).also { scrollBehaviorOwner = it }
126     }
127 
128     private val updateScrollBehaviorsConnection =
129         object : NestedScrollConnection {
130             /**
131              * When using [NestedScrollConnection.onPostScroll], we can specify the desired behavior
132              * before our parent components. This gives them the option to override our behavior if
133              * they choose.
134              *
135              * The behavior can be communicated at every scroll gesture to ensure that the hierarchy
136              * is respected, even if one of our descendant nodes changes behavior after we set it.
137              */
onPostScrollnull138             override fun onPostScroll(
139                 consumed: Offset,
140                 available: Offset,
141                 source: NestedScrollSource,
142             ): Offset {
143                 // If we have some remaining scroll, that scroll can be used to initiate a
144                 // transition between scenes. We can assume that the behavior is only needed if
145                 // there is some remaining amount.
146                 if (available != Offset.Zero) {
147                     findScrollBehaviorOwner()
148                         ?.updateScrollBehaviors(
149                             topOrLeftBehavior = topOrLeftBehavior,
150                             bottomOrRightBehavior = bottomOrRightBehavior,
151                             isExternalOverscrollGesture = isExternalOverscrollGesture,
152                         )
153                 }
154 
155                 return Offset.Zero
156             }
157         }
158 
159     init {
160         delegate(nestedScrollModifierNode(updateScrollBehaviorsConnection, dispatcher = null))
161     }
162 
onDetachnull163     override fun onDetach() {
164         scrollBehaviorOwner = null
165     }
166 
updatenull167     fun update(
168         draggableHandler: DraggableHandlerImpl,
169         topOrLeftBehavior: NestedScrollBehavior,
170         bottomOrRightBehavior: NestedScrollBehavior,
171         isExternalOverscrollGesture: () -> Boolean,
172     ) {
173         this.draggableHandler = draggableHandler
174         this.topOrLeftBehavior = topOrLeftBehavior
175         this.bottomOrRightBehavior = bottomOrRightBehavior
176         this.isExternalOverscrollGesture = isExternalOverscrollGesture
177     }
178 }
179