Skip to content

Commit 147a5f8

Browse files
committed
chore: improve sheet resignment behavior to play nice with default material3 overshoot
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 0eb3539 commit 147a5f8

2 files changed

Lines changed: 87 additions & 65 deletions

File tree

ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.getcode.navigation.scenes
22

33
import android.os.Build
44
import android.os.Parcelable
5+
import androidx.compose.foundation.LocalOverscrollFactory
56
import androidx.compose.foundation.layout.Box
67
import androidx.compose.foundation.layout.WindowInsets
78
import androidx.compose.foundation.layout.fillMaxHeight
@@ -39,6 +40,7 @@ import com.getcode.navigation.results.NavResultKey
3940
import com.getcode.navigation.results.NavResultOrCanceled
4041
import com.getcode.navigation.results.NavResultStore
4142
import com.getcode.navigation.results.NavigationRetVal
43+
import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy.Companion.modalBottomSheet
4244
import com.getcode.theme.CodeTheme
4345
import com.getcode.ui.utils.LocalSheetGesturesState
4446
import kotlinx.coroutines.launch
@@ -89,8 +91,12 @@ internal class ModalBottomSheetScene<T : Any> @OptIn(ExperimentalMaterial3Api::c
8991
}
9092
}
9193

94+
var allowDismiss by rememberSaveable { mutableStateOf(!navigator.sheetDragDisabled) }
9295
var sheetState: SheetState = rememberModalBottomSheetState(
9396
skipPartiallyExpanded = true,
97+
confirmValueChange = { value ->
98+
value != SheetValue.Hidden || allowDismiss
99+
}
94100
)
95101

96102
// Ensure the sheet shows when entering composition. Material3's internal
@@ -134,21 +140,17 @@ internal class ModalBottomSheetScene<T : Any> @OptIn(ExperimentalMaterial3Api::c
134140
}
135141
}
136142

137-
var gesturesEnabled by rememberSaveable(!navigator.sheetDragDisabled) {
138-
mutableStateOf(!navigator.sheetDragDisabled)
139-
}
140-
141143
CompositionLocalProvider(
142144
LocalSheetGesturesState provides { enabled ->
143-
gesturesEnabled = enabled && !navigator.sheetDragDisabled
145+
allowDismiss = enabled && !navigator.sheetDragDisabled
144146
},
145147
) {
146148
// Remove inset padding. Default adds nav bar padding.
147149
// Remove grab bar for bleed to top edge of sheet
148150
ModalBottomSheet(
149151
sheetState = sheetState,
150152
onDismissRequest = { if (!isNonDismissable) dismiss(false) },
151-
sheetGesturesEnabled = gesturesEnabled,
153+
sheetGesturesEnabled = !navigator.sheetDragDisabled,
152154
scrimColor = CodeTheme.colors.scrim,
153155
properties = if (isNonDismissable) {
154156
ModalBottomSheetProperties(

ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetResignmentModifierNode.kt

Lines changed: 79 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,116 +4,136 @@ import androidx.compose.foundation.lazy.LazyListState
44
import androidx.compose.runtime.Composable
55
import androidx.compose.runtime.derivedStateOf
66
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.remember
78
import androidx.compose.runtime.snapshotFlow
89
import androidx.compose.ui.Modifier
910
import androidx.compose.ui.geometry.Offset
1011
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
1112
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
13+
import androidx.compose.ui.input.nestedscroll.nestedScroll
1214
import androidx.compose.ui.node.DelegatableNode
1315
import androidx.compose.ui.node.ModifierNodeElement
1416
import androidx.compose.ui.platform.InspectorInfo
1517
import kotlinx.coroutines.Job
1618
import kotlinx.coroutines.delay
19+
import kotlinx.coroutines.flow.collectLatest
1720
import 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+
1958
private 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
111128
fun 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

Comments
 (0)