@@ -4,116 +4,136 @@ import androidx.compose.foundation.lazy.LazyListState
44import androidx.compose.runtime.Composable
55import androidx.compose.runtime.derivedStateOf
66import androidx.compose.runtime.getValue
7+ import androidx.compose.runtime.remember
78import androidx.compose.runtime.snapshotFlow
89import androidx.compose.ui.Modifier
910import androidx.compose.ui.geometry.Offset
1011import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
1112import androidx.compose.ui.input.nestedscroll.NestedScrollSource
13+ import androidx.compose.ui.input.nestedscroll.nestedScroll
1214import androidx.compose.ui.node.DelegatableNode
1315import androidx.compose.ui.node.ModifierNodeElement
1416import androidx.compose.ui.platform.InspectorInfo
1517import kotlinx.coroutines.Job
1618import kotlinx.coroutines.delay
19+ import kotlinx.coroutines.flow.collectLatest
1720import kotlinx.coroutines.launch
1821
22+ internal class SheetResignmentConnection (
23+ internal val setGesturesEnabled : (Boolean ) -> Unit ,
24+ private val autoResetDelayMs : Long ,
25+ ) : NestedScrollConnection {
26+
27+ var waitingForSecondDrag = false
28+ var resetJob: Job ? = null
29+
30+ override fun onPreScroll (available : Offset , source : NestedScrollSource ): Offset {
31+ if (waitingForSecondDrag && available.y < 0f ) {
32+ waitingForSecondDrag = false
33+ resetJob?.cancel()
34+ setGesturesEnabled(true )
35+ }
36+ return Offset .Zero
37+ }
38+
39+ fun onAtTop () {
40+ resetJob?.cancel()
41+ waitingForSecondDrag = true
42+ setGesturesEnabled(false )
43+ }
44+
45+ fun onScrolledAway () {
46+ resetJob?.cancel()
47+ waitingForSecondDrag = false
48+ setGesturesEnabled(false )
49+ }
50+
51+ fun onDetach () {
52+ resetJob?.cancel()
53+ waitingForSecondDrag = false
54+ setGesturesEnabled(true )
55+ }
56+ }
57+
1958private class SheetResignmentModifierNode (
2059 private val listState : LazyListState ,
21- private val setGesturesEnabled : ( Boolean ) -> Unit ,
22- private val autoResetDelayMs : Long = 700L // time after which first-drag block expires
60+ private val connection : SheetResignmentConnection ,
61+ private val autoResetDelayMs : Long ,
2362) : Modifier.Node(), DelegatableNode {
2463
2564 private val isAtTop by derivedStateOf {
2665 listState.firstVisibleItemIndex == 0 &&
2766 listState.firstVisibleItemScrollOffset == 0
2867 }
2968
30- private var waitingForSecondDrag = false
31- private var resetJob: Job ? = null
3269 private var observeJob: Job ? = null
3370
34- private val connection = object : NestedScrollConnection {
35- override fun onPreScroll (available : Offset , source : NestedScrollSource ): Offset {
36- if (isAtTop && waitingForSecondDrag && available.y < 0f ) { // downward attempt
37- // Consume the first downward motion → sheet doesn't move at all
38- waitingForSecondDrag = false
39- resetJob?.cancel()
40- return available.copy(x = 0f ) // fully consume → no sheet motion
41- }
42- return Offset .Zero
43- }
44- }
45-
4671 override fun onAttach () {
4772 super .onAttach()
4873 observeJob = coroutineScope.launch {
49- snapshotFlow { isAtTop }
50- .collect { atTop -> updateGestures(atTop) }
51- }
52- }
53-
54- private fun updateGestures (atTop : Boolean ) {
55- resetJob?.cancel()
56-
57- if (atTop) {
58- // Just reached top → arm the "reject first downward drag"
59- waitingForSecondDrag = true
60- setGesturesEnabled(false ) // sheet drag disabled initially
61-
62- // Optional: auto-allow after delay (so pause → second drag works)
63- if (autoResetDelayMs > 0 ) {
64- resetJob = coroutineScope.launch {
65- delay(autoResetDelayMs)
66- waitingForSecondDrag = false
67- setGesturesEnabled(true ) // now allow dismiss
74+ snapshotFlow { isAtTop }.collectLatest { atTop ->
75+ if (atTop) {
76+ connection.onAtTop()
77+ if (autoResetDelayMs > 0 ) {
78+ connection.resetJob = coroutineScope.launch {
79+ delay(autoResetDelayMs)
80+ connection.waitingForSecondDrag = false
81+ connection.setGesturesEnabled(true )
82+ }
83+ }
84+ } else {
85+ connection.onScrolledAway()
6886 }
6987 }
70- } else {
71- // Scrolled away from top → normal scrolling mode, sheet drag disabled
72- waitingForSecondDrag = false
73- setGesturesEnabled(false )
7488 }
7589 }
7690
7791 override fun onDetach () {
7892 observeJob?.cancel()
79- resetJob?.cancel()
80- setGesturesEnabled(true ) // restore default
93+ connection.onDetach()
8194 super .onDetach()
8295 }
8396}
8497
85- private data class TwoStepDismissElement (
98+ private data class SheetResignmentElement (
8699 val listState : LazyListState ,
87- val setGesturesEnabled : ( Boolean ) -> Unit ,
88- val autoResetDelayMs : Long
100+ val connection : SheetResignmentConnection ,
101+ val autoResetDelayMs : Long ,
89102) : ModifierNodeElement<SheetResignmentModifierNode>() {
90103
91- override fun create (): SheetResignmentModifierNode =
92- SheetResignmentModifierNode (listState, setGesturesEnabled, autoResetDelayMs)
104+ override fun create () = SheetResignmentModifierNode (listState, connection, autoResetDelayMs)
93105
94- override fun update (node : SheetResignmentModifierNode ) {
95- // No dynamic update needed (listState & lambda are stable)
96- }
106+ override fun update (node : SheetResignmentModifierNode ) = Unit
97107
98108 override fun InspectorInfo.inspectableProperties () {
99- name = " twoStepSheetDismiss "
109+ name = " sheetResignmentBehavior "
100110 properties[" listState" ] = listState
101111 properties[" autoResetDelayMs" ] = autoResetDelayMs
102112 }
103113}
104114
115+
105116/* *
106- * Reusable modifier: Prevents sheet from starting dismiss on first downward drag after list reaches top.
107- * - Consumes initial downward delta → no visible movement/snap-back.
108- * - Enables sheet gestures when at top (until scroll away or auto-reset).
117+ * Prevents a [ModalBottomSheet] from starting dismiss on the first downward drag after the
118+ * internal [LazyList] reaches the top. The first drag triggers overscroll bounce only;
119+ * a subsequent downward drag (or after [autoResetDelayMs]) will dismiss normally.
120+ *
121+ * Requires [LocalSheetGesturesState] to be provided, or pass [setGesturesEnabled] explicitly.
122+ *
123+ * @param listState the [LazyListState] of the list inside the sheet
124+ * @param setGesturesEnabled callback to toggle sheet gesture handling (e.g. `sheetState.gesturesEnabled`)
125+ * @param autoResetDelayMs time after which a paused-then-drag will re-enable dismiss; 0 to disable
109126 */
110127@Composable
111128fun Modifier.sheetResignmentBehavior (
112129 listState : LazyListState ,
113130 setGesturesEnabled : (Boolean ) -> Unit = LocalSheetGesturesState .current,
114- autoResetDelayMs : Long = 700L // 0L to disable auto-reset → strict "scroll away first"
115- ): Modifier = this then TwoStepDismissElement (
116- listState = listState,
117- setGesturesEnabled = setGesturesEnabled,
118- autoResetDelayMs = autoResetDelayMs
119- )
131+ autoResetDelayMs : Long = 700L,
132+ ): Modifier {
133+ val connection = remember(setGesturesEnabled, autoResetDelayMs) {
134+ SheetResignmentConnection (setGesturesEnabled, autoResetDelayMs)
135+ }
136+ return this
137+ .nestedScroll(connection)
138+ .then(SheetResignmentElement (listState, connection, autoResetDelayMs))
139+ }
0 commit comments