• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2024 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.gesture
18 
19 import androidx.compose.runtime.Composable
20 import androidx.compose.runtime.getValue
21 import androidx.compose.runtime.mutableStateOf
22 import androidx.compose.runtime.setValue
23 import androidx.compose.ui.Modifier
24 import androidx.compose.ui.geometry.Offset
25 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
26 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
27 import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode
28 import androidx.compose.ui.node.DelegatingNode
29 import androidx.compose.ui.node.ModifierNodeElement
30 import androidx.compose.ui.platform.LocalLayoutDirection
31 import androidx.compose.ui.unit.LayoutDirection
32 import androidx.compose.ui.unit.Velocity
33 
34 /**
35  * Update [state] and disallow outer scroll after a child node consumed a non-zero scroll amount
36  * before reaching its [bounds], so that the child is overscrolled instead of letting the outer
37  * scrollable(s) consume the extra scroll.
38  *
39  * Example:
40  * ```
41  * val nestedScrollControlState = remember { NestedScrollControlState() }
42  * Column(
43  *     Modifier
44  *         // Note: Any scrollable/draggable parent should use nestedScrollControlState to
45  *         // enable/disable themselves.
46  *         .verticalScroll(
47  *             rememberScrollState(),
48  *             enabled = nestedScrollControlState.isOuterScrollAllowed,
49  *         )
50  * ) {
51  *     Column(
52  *         Modifier
53  *             .nestedScrollController(nestedScrollControlState)
54  *             .verticalScroll(rememberScrollState())
55  *     ) { ...}
56  * }
57  * ```
58  */
nestedScrollControllernull59 fun Modifier.nestedScrollController(
60     state: NestedScrollControlState,
61     bounds: NestedScrollableBound = NestedScrollableBound.Any,
62 ): Modifier {
63     return this.then(NestedScrollControllerElement(state, bounds))
64 }
65 
66 /**
67  * A state that should be used by outer scrollables to disable themselves so that nested scrollables
68  * will overscroll when reaching their bounds.
69  *
70  * @see nestedScrollController
71  */
72 class NestedScrollControlState {
73     var isOuterScrollAllowed by mutableStateOf(true)
74         internal set
75 }
76 
77 /**
78  * Specifies when to disable outer scroll after reaching the bounds of a nested scrollable.
79  *
80  * @see nestedScrollController
81  */
82 enum class NestedScrollableBound {
83     /** Disable after reaching any of the scrollable bounds. */
84     Any,
85 
86     /** Disable after reaching the top (left) bound when scrolling vertically (horizontally). */
87     TopLeft,
88 
89     /** Disable after reaching the bottom (right) bound when scrolling vertically (horizontally). */
90     BottomRight;
91 
92     companion object {
93         /**
94          * Disable after reaching the left (right) bound when scrolling horizontally in a LTR (RTL)
95          * layout.
96          */
97         val Start: NestedScrollableBound
98             @Composable
99             get() =
100                 when (LocalLayoutDirection.current) {
101                     LayoutDirection.Ltr -> TopLeft
102                     LayoutDirection.Rtl -> BottomRight
103                 }
104 
105         /**
106          * Disable after reaching the right (left) bound when scrolling horizontally in a LTR (RTL)
107          * layout.
108          */
109         val End: NestedScrollableBound
110             @Composable
111             get() =
112                 when (LocalLayoutDirection.current) {
113                     LayoutDirection.Ltr -> BottomRight
114                     LayoutDirection.Rtl -> TopLeft
115                 }
116     }
117 }
118 
119 private data class NestedScrollControllerElement(
120     private val state: NestedScrollControlState,
121     private val bounds: NestedScrollableBound,
122 ) : ModifierNodeElement<NestedScrollControllerNode>() {
createnull123     override fun create(): NestedScrollControllerNode {
124         return NestedScrollControllerNode(state, bounds)
125     }
126 
updatenull127     override fun update(node: NestedScrollControllerNode) {
128         node.update(state, bounds)
129     }
130 }
131 
132 private class NestedScrollControllerNode(
133     private var state: NestedScrollControlState,
134     private var bounds: NestedScrollableBound,
135 ) : DelegatingNode(), NestedScrollConnection {
136     private var childrenConsumedAnyScroll = false
137     private var availableOnPreScroll = Offset.Zero
138 
139     init {
140         delegate(nestedScrollModifierNode(this, dispatcher = null))
141     }
142 
onDetachnull143     override fun onDetach() {
144         state.isOuterScrollAllowed = true
145     }
146 
updatenull147     fun update(controller: NestedScrollControlState, bounds: NestedScrollableBound) {
148         if (controller != this.state) {
149             controller.isOuterScrollAllowed = this.state.isOuterScrollAllowed
150             this.state.isOuterScrollAllowed = true
151             this.state = controller
152         }
153 
154         this.bounds = bounds
155     }
156 
onPreScrollnull157     override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
158         availableOnPreScroll = available
159         return Offset.Zero
160     }
161 
onPostScrollnull162     override fun onPostScroll(
163         consumed: Offset,
164         available: Offset,
165         source: NestedScrollSource,
166     ): Offset {
167         val consumedIncludingPreScroll = availableOnPreScroll - available
168         if (
169             hasConsumedScrollInBounds(consumedIncludingPreScroll.x) ||
170                 hasConsumedScrollInBounds(consumedIncludingPreScroll.y)
171         ) {
172             childrenConsumedAnyScroll = true
173         }
174 
175         if (!childrenConsumedAnyScroll) {
176             state.isOuterScrollAllowed = true
177         } else {
178             state.isOuterScrollAllowed = false
179         }
180 
181         return Offset.Zero
182     }
183 
onPostFlingnull184     override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
185         childrenConsumedAnyScroll = false
186         state.isOuterScrollAllowed = true
187         return super.onPostFling(consumed, available)
188     }
189 
hasConsumedScrollInBoundsnull190     private fun hasConsumedScrollInBounds(consumed: Float): Boolean {
191         return when {
192             consumed < 0f ->
193                 bounds == NestedScrollableBound.Any || bounds == NestedScrollableBound.BottomRight
194 
195             consumed > 0f ->
196                 bounds == NestedScrollableBound.Any || bounds == NestedScrollableBound.TopLeft
197 
198             else -> false
199         }
200     }
201 }
202