Skip to content

Commit 7abe351

Browse files
committed
fix(sheets): let half sheets own their height and expand only when needed
The convert destination picker set its own height inside the sheet: a BoxWithConstraints capped the list at 92.5% of the space the sheet had already handed it, so at the expanded detent the list stopped ~64dp short of the sheet's bottom edge and stranded its edge fade there. Height is the sheet's business — the detent constrains the content, and the content should just fill it. A half sheet was also always draggable up to expanded, even when its content already fit in the resting half and expanding would only reveal empty space. Content now reports whether it overruns the resting detent via LocalSheetExpansionState, and the scene strategy adds the expanded detent to the sheet's detent list only while that holds (never withdrawing it while the sheet sits at or heads toward expanded). Content-dependent detents were tried first and rejected: compose-unstyled measures content against the full container once any detent depends on sheet height, so the list overran the laid-out sheet and clipped. Keeping the detents container-relative and varying the detent list instead avoids that. The trade-off is that a short list still rests at the full half detent rather than hugging its content.
1 parent 292aa3f commit 7abe351

5 files changed

Lines changed: 114 additions & 46 deletions

File tree

‎apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/SwapFlowScreen.kt‎

Lines changed: 28 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
package com.flipcash.app.tokens
22

33
import androidx.compose.foundation.layout.Column
4-
import androidx.compose.foundation.layout.BoxWithConstraints
54
import androidx.compose.foundation.layout.fillMaxSize
65
import androidx.compose.foundation.layout.fillMaxWidth
7-
import androidx.compose.foundation.layout.heightIn
86
import androidx.compose.foundation.layout.navigationBarsPadding
97
import androidx.compose.foundation.layout.padding
108
import androidx.compose.material.Text
@@ -134,12 +132,6 @@ private fun swapEntryProvider(
134132
}
135133
}
136134

137-
/** Mirrors the modal sheet's expanded detent so wrap-content sheets never overrun it. */
138-
private const val SheetExpandedFraction = 0.925f
139-
140-
/** Floor for the currency sheet: a short list still opens to a half-height sheet, not a stub. */
141-
private const val SheetMinimumFraction = 0.5f
142-
143135
/**
144136
* Destination picker for a conversion. Selecting a currency updates the in-flight purpose and pops
145137
* straight back to amount entry — nothing else in the flow changes, so there's no resolve to await.
@@ -153,40 +145,35 @@ private fun ConvertDestinationSelectScreen() {
153145

154146
val convert = purpose as? SwapPurpose.Convert ?: return
155147

156-
// The sheet hugs its content, so the list is measured at its natural height — bounded to
157-
// max(half screen, content) so a short list isn't a stub and a long one can't run past the
158-
// sheet's expanded detent.
159-
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
160-
Column(
148+
// Height is the sheet's business, not the content's: the sheet hands down its current detent
149+
// height and the list simply fills it. Re-stating a fraction of the screen here would fight
150+
// that — it capped the list short of the sheet's own bottom edge, stranding the list's edge
151+
// fade above it.
152+
Column(
153+
modifier = Modifier
154+
.fillMaxWidth()
155+
// Sheets stop short of the system bars, so pad for them here.
156+
.navigationBarsPadding(),
157+
) {
158+
// A sheet has no app bar: the title sits flush-left above the list, and the sheet's own
159+
// scrim/drag handles dismissal.
160+
Text(
161161
modifier = Modifier
162-
.fillMaxWidth()
163-
.heightIn(
164-
min = maxHeight * SheetMinimumFraction,
165-
max = maxHeight * SheetExpandedFraction,
166-
)
167-
// Wrap-content sheets stop short of the system bars, so pad for them here.
168-
.navigationBarsPadding(),
169-
) {
170-
// A sheet has no app bar: the title sits flush-left above the list, and the sheet's own
171-
// scrim/drag handles dismissal.
172-
Text(
173-
modifier = Modifier
174-
.padding(horizontal = CodeTheme.dimens.inset)
175-
.padding(
176-
top = CodeTheme.dimens.staticGrid.x7,
177-
bottom = CodeTheme.dimens.staticGrid.x5,
178-
),
179-
text = stringResource(R.string.title_selectCurrency),
180-
style = CodeTheme.typography.textLarge,
181-
color = CodeTheme.colors.textMain,
182-
)
183-
184-
TokenSelectScreen(
185-
purpose = TokenPurpose.ConvertDestination(convert.mint, convert.destinationMint),
186-
showTopBar = false,
187-
presentation = TokenListPresentation.Sheet,
188-
)
189-
}
162+
.padding(horizontal = CodeTheme.dimens.inset)
163+
.padding(
164+
top = CodeTheme.dimens.staticGrid.x7,
165+
bottom = CodeTheme.dimens.staticGrid.x5,
166+
),
167+
text = stringResource(R.string.title_selectCurrency),
168+
style = CodeTheme.typography.textLarge,
169+
color = CodeTheme.colors.textMain,
170+
)
171+
172+
TokenSelectScreen(
173+
purpose = TokenPurpose.ConvertDestination(convert.mint, convert.destinationMint),
174+
showTopBar = false,
175+
presentation = TokenListPresentation.Sheet,
176+
)
190177
}
191178

192179
LaunchedEffect(selectionViewModel) {

‎apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenList.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import com.getcode.solana.keys.base58
3939
import com.getcode.theme.CodeTheme
4040
import com.getcode.ui.core.isScrolledToEnd
4141
import com.getcode.ui.core.verticalScrollStateGradient
42+
import com.getcode.ui.utils.AllowSheetExpansionWhenScrollable
4243
import com.getcode.ui.utils.sheetResignmentBehavior
4344

4445
/**
@@ -102,6 +103,12 @@ fun TokenList(
102103
) {
103104
val listState = rememberLazyListState()
104105

106+
// A wrap-height list is a sheet picker: let the host sheet know whether the list has anything
107+
// below the fold, so it only becomes draggable-to-expanded when expanding would show more.
108+
if (presentation.wrapHeight) {
109+
AllowSheetExpansionWhenScrollable(listState)
110+
}
111+
105112
val cashReserves = remember(tokens) {
106113
tokens?.find { it.token.address == Mint.usdf }?.balance ?: LocalFiat.Zero
107114
}

‎ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ interface Sheet: NavKey
66
interface WrapContentSheet: NavKey
77

88
/**
9-
* Opens at a half-height resting detent and can be dragged the rest of the way up, so the top of
10-
* the screen underneath stays visible. Content shorter than the detent bottom-aligns as usual, so
11-
* pair this with [WrapContentSheet] and a half-height floor if a stub sheet isn't wanted.
9+
* Rests at half the screen height, so the top of the screen underneath stays visible.
10+
*
11+
* The sheet fills that height whatever the content's own height is, so content should size itself
12+
* to what it is given rather than restating the fraction. It is draggable further up only while the
13+
* content reports it has something below the fold — see
14+
* [com.getcode.ui.utils.AllowSheetExpansionWhenScrollable].
1215
*/
1316
interface HalfSheet: NavKey
1417
interface NonDismissableRoute: NavKey

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

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import androidx.compose.runtime.SideEffect
1818
import androidx.compose.runtime.getValue
1919
import androidx.compose.runtime.key
2020
import androidx.compose.runtime.mutableStateOf
21+
import androidx.compose.runtime.remember
2122
import androidx.compose.runtime.rememberCoroutineScope
2223
import androidx.compose.runtime.saveable.rememberSaveable
2324
import androidx.compose.runtime.setValue
@@ -50,6 +51,7 @@ import com.getcode.navigation.scrim.LocalScrimController
5051
import com.getcode.navigation.scrim.ScrimOverlay
5152
import com.getcode.theme.CodeTheme
5253
import com.getcode.ui.core.noRippleClickable
54+
import com.getcode.ui.utils.LocalSheetExpansionState
5355
import com.getcode.ui.utils.LocalSheetGesturesState
5456
import kotlinx.coroutines.launch
5557

@@ -62,7 +64,14 @@ private val Expanded = SheetDetent("expanded") { containerHeight, _ ->
6264
containerHeight * 0.925f
6365
}
6466

65-
/** Resting detent for a [com.getcode.navigation.HalfSheet]; still draggable up to [Expanded]. */
67+
/**
68+
* Resting — and, for short content, only — detent for a [com.getcode.navigation.HalfSheet].
69+
*
70+
* The sheet always fills this detent, so its content is measured against it and does not need to
71+
* restate the fraction. [Expanded] joins the detent list only once the content reports that it
72+
* overruns this one, so a sheet with nothing below the fold can't be dragged up into dead space —
73+
* see [com.getcode.ui.utils.LocalSheetExpansionState].
74+
*/
6675
private val Half = SheetDetent("half") { containerHeight, _ ->
6776
containerHeight * 0.5f
6877
}
@@ -129,10 +138,19 @@ internal class ModalBottomSheetScene<T : Any> constructor(
129138
val isHalfSheet = metadata[NavMetadataKeys.IsHalfSheet.key] as? Boolean ?: false
130139
val restingDetent = if (isHalfSheet) Half else Expanded
131140

141+
// Whether the sheet's content overruns [restingDetent]. Half sheets start out
142+
// unexpandable and are handed [Expanded] only once their content says it needs it.
143+
var contentOverflows by remember { mutableStateOf(false) }
144+
// Kept stable: this backs a static local, so a fresh lambda each pass would
145+
// needlessly invalidate the whole sheet subtree.
146+
val setContentOverflows = remember { { overflows: Boolean ->
147+
contentOverflows = overflows
148+
} }
149+
132150
val sheetState = rememberBottomSheetState(
133151
initialDetent = SheetDetent.Hidden,
134152
detents = if (isHalfSheet) {
135-
listOf(SheetDetent.Hidden, Half, Expanded)
153+
listOf(SheetDetent.Hidden, Half)
136154
} else {
137155
listOf(SheetDetent.Hidden, Expanded)
138156
},
@@ -141,6 +159,20 @@ internal class ModalBottomSheetScene<T : Any> constructor(
141159
},
142160
)
143161

162+
// Offer the expanded detent only while there is something below the fold to expand into,
163+
// so a sheet that already fits can't be dragged up into empty space. Never withdraw it
164+
// while the sheet is sitting at (or heading toward) it.
165+
LaunchedEffect(isHalfSheet, contentOverflows) {
166+
if (!isHalfSheet) return@LaunchedEffect
167+
val atExpanded = sheetState.currentDetent == Expanded ||
168+
sheetState.targetDetent == Expanded
169+
sheetState.detents = if (contentOverflows || atExpanded) {
170+
listOf(SheetDetent.Hidden, Half, Expanded)
171+
} else {
172+
listOf(SheetDetent.Hidden, Half)
173+
}
174+
}
175+
144176
// Animate the sheet in on first composition and after
145177
// same-route dismiss-replace (sheetGeneration increments).
146178
LaunchedEffect(navigator.sheetGeneration) {
@@ -186,6 +218,7 @@ internal class ModalBottomSheetScene<T : Any> constructor(
186218
LocalSheetGesturesState provides { enabled ->
187219
allowDismiss = enabled && !navigator.sheetDragDisabled
188220
},
221+
LocalSheetExpansionState provides setContentOverflows,
189222
LocalScrimController provides scrim,
190223
) {
191224
BackHandler(enabled = effectiveProperties.dismissOnBackPress) {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.getcode.ui.utils
2+
3+
import androidx.compose.foundation.lazy.LazyListState
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.runtime.DisposableEffect
6+
import androidx.compose.runtime.derivedStateOf
7+
import androidx.compose.runtime.getValue
8+
import androidx.compose.runtime.remember
9+
import androidx.compose.runtime.staticCompositionLocalOf
10+
11+
/**
12+
* Lets sheet content tell the host sheet whether it overruns the resting detent.
13+
*
14+
* A [com.getcode.navigation.HalfSheet] rests at half the screen, so content that fits in that half
15+
* is already showing everything it has; dragging up would only reveal empty space. The host
16+
* therefore withholds the expanded detent until content reports `true` here.
17+
*/
18+
val LocalSheetExpansionState = staticCompositionLocalOf<(Boolean) -> Unit> { { } }
19+
20+
/**
21+
* Reports the host sheet as expandable for as long as [scrollState] has content out of view.
22+
*
23+
* A list that can't scroll at the resting detent has nothing to gain from expanding. The report is
24+
* reset when the content leaves composition so the next sheet starts from its own measurement.
25+
*/
26+
@Composable
27+
fun AllowSheetExpansionWhenScrollable(scrollState: LazyListState) {
28+
val setExpandable = LocalSheetExpansionState.current
29+
30+
val hasContentOutOfView by remember(scrollState) {
31+
derivedStateOf { scrollState.canScrollForward || scrollState.canScrollBackward }
32+
}
33+
34+
DisposableEffect(setExpandable, hasContentOutOfView) {
35+
setExpandable(hasContentOutOfView)
36+
onDispose { setExpandable(false) }
37+
}
38+
}

0 commit comments

Comments
 (0)