From 084317deac29f634afda930111db5902afec5c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 5 Feb 2026 16:44:36 +0800 Subject: [PATCH] Unify sheets swipe-to-dismiss gating --- .../nekohasekai/sfa/compose/MainActivity.kt | 73 ++--- .../connections/ConnectionDetailsScreen.kt | 16 +- .../screen/connections/ConnectionsScreen.kt | 265 +++++++++++++----- .../compose/screen/dashboard/GroupsCard.kt | 117 +++++--- .../sfa/compose/util/SheetNestedScroll.kt | 58 ++++ 5 files changed, 384 insertions(+), 145 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/util/SheetNestedScroll.kt diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 5db48b4..1cb56bf 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -1014,46 +1014,47 @@ class MainActivity : Column( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.9f), + .fillMaxHeight(), ) { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.title_groups), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - if (groupsUiState.groups.isNotEmpty()) { - IconButton(onClick = { groupsViewModel.toggleAllGroups() }) { - Icon( - imageVector = if (allCollapsed) { - Icons.Default.UnfoldMore - } else { - Icons.Default.UnfoldLess - }, - contentDescription = if (allCollapsed) { - stringResource(R.string.expand_all) - } else { - stringResource(R.string.collapse_all) - }, - ) - } - } - } - // Groups content GroupsCard( serviceStatus = currentServiceStatus, commandClient = dashboardViewModel.commandClient, viewModel = groupsViewModel, + listHeaderContent = { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.title_groups), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (groupsUiState.groups.isNotEmpty()) { + IconButton(onClick = { groupsViewModel.toggleAllGroups() }) { + Icon( + imageVector = if (allCollapsed) { + Icons.Default.UnfoldMore + } else { + Icons.Default.UnfoldLess + }, + contentDescription = if (allCollapsed) { + stringResource(R.string.expand_all) + } else { + stringResource(R.string.collapse_all) + }, + ) + } + } + } + }, + asSheet = true, modifier = Modifier.fillMaxSize(), ) } @@ -1097,7 +1098,7 @@ class MainActivity : Column( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.9f), + .fillMaxHeight(), ) { if (displayConnection != null) { ConnectionDetailsScreen( @@ -1106,11 +1107,13 @@ class MainActivity : onClose = { selectedConnectionId?.let { connectionsViewModel.closeConnection(it) } }, + asSheet = true, ) } else { ConnectionsPage( serviceStatus = currentServiceStatus, viewModel = connectionsViewModel, + asSheet = true, showTitle = true, onConnectionClick = { selectedConnectionId = it }, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt index fab586e..8671fd1 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll @@ -46,6 +47,7 @@ import androidx.compose.ui.unit.dp import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.model.Connection +import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -57,17 +59,25 @@ fun ConnectionDetailsScreen( onClose: () -> Unit, modifier: Modifier = Modifier, showHeader: Boolean = true, + asSheet: Boolean = false, ) { val dateTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) var showMenu by remember { mutableStateOf(false) } val scrollState = rememberScrollState() - val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(scrollState) + val scrollModifier = + if (asSheet) { + rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier { + scrollState.value == 0 + } + } else { + Modifier.nestedScroll(rememberBounceBlockingNestedScrollConnection(scrollState)) + } Column( modifier = modifier .fillMaxSize() - .nestedScroll(bounceBlockingConnection) - .verticalScroll(scrollState), + .then(scrollModifier) + .verticalScroll(scrollState, overscrollEffect = if (asSheet) null else rememberOverscrollEffect()), ) { if (showHeader) { Row( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt index b8d0053..5946713 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt @@ -17,7 +17,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check @@ -44,6 +44,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -62,6 +63,7 @@ import io.nekohasekai.sfa.compose.model.Connection import io.nekohasekai.sfa.compose.model.ConnectionSort import io.nekohasekai.sfa.compose.model.ConnectionStateFilter import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier import io.nekohasekai.sfa.constant.Status @OptIn(ExperimentalMaterial3Api::class) @@ -69,6 +71,7 @@ import io.nekohasekai.sfa.constant.Status fun ConnectionsPage( serviceStatus: Status, viewModel: ConnectionsViewModel = viewModel(), + asSheet: Boolean = false, showTitle: Boolean = true, showTopBar: Boolean = false, onConnectionClick: (String) -> Unit = {}, @@ -87,14 +90,21 @@ fun ConnectionsPage( } } - Column( - modifier = modifier.fillMaxSize(), - ) { - Row( - modifier = Modifier + val headerRowModifier = + if (asSheet) { + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + } else { + Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .padding(bottom = 16.dp), + .padding(bottom = 16.dp) + } + + val headerContent: @Composable () -> Unit = { + Row( + modifier = headerRowModifier, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -258,13 +268,29 @@ fun ConnectionsPage( } } } + } + if (asSheet) { ConnectionsScreen( serviceStatus = serviceStatus, viewModel = viewModel, onConnectionClick = { connection -> onConnectionClick(connection.id) }, - modifier = Modifier.fillMaxSize(), + listHeaderContent = headerContent, + asSheet = true, + modifier = modifier.fillMaxSize(), ) + } else { + Column( + modifier = modifier.fillMaxSize(), + ) { + headerContent() + ConnectionsScreen( + serviceStatus = serviceStatus, + viewModel = viewModel, + onConnectionClick = { connection -> onConnectionClick(connection.id) }, + modifier = Modifier.fillMaxSize(), + ) + } } } @@ -347,6 +373,8 @@ fun ConnectionsScreen( serviceStatus: Status, viewModel: ConnectionsViewModel = viewModel(), onConnectionClick: (Connection) -> Unit = {}, + listHeaderContent: (@Composable () -> Unit)? = null, + asSheet: Boolean = false, modifier: Modifier = Modifier, ) { val uiState by viewModel.uiState.collectAsState() @@ -365,73 +393,96 @@ fun ConnectionsScreen( viewModel.updateServiceStatus(serviceStatus) } - Column(modifier = modifier.fillMaxSize()) { - AnimatedVisibility( - visible = uiState.isSearchActive, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - val focusRequester = remember { FocusRequester() } + val lazyListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } - LaunchedEffect(Unit) { - focusRequester.requestFocus() + if (asSheet) { + val sheetSwipeToDismissModifier = + rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier { + lazyListState.firstVisibleItemIndex == 0 && + lazyListState.firstVisibleItemScrollOffset == 0 } - - OutlinedTextField( - value = uiState.searchText, - onValueChange = { viewModel.setSearchText(it) }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp) - .focusRequester(focusRequester), - placeholder = { Text(stringResource(R.string.search_connections)) }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - trailingIcon = { - if (uiState.searchText.isNotEmpty()) { - IconButton(onClick = { viewModel.setSearchText("") }) { - Icon(Icons.Default.Clear, contentDescription = null) - } - } - }, - singleLine = true, - ) - } - - when { - uiState.isLoading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() + LazyColumn( + modifier = + modifier + .fillMaxSize() + .then(sheetSwipeToDismissModifier), + state = lazyListState, + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + overscrollEffect = null, + ) { + if (listHeaderContent != null) { + item(key = "connections_list_header") { + listHeaderContent() } } - uiState.connections.isEmpty() -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, + item(key = "connections_search") { + AnimatedVisibility( + visible = uiState.isSearchActive, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), ) { - Text( - text = stringResource(R.string.empty_connections), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + value = uiState.searchText, + onValueChange = { viewModel.setSearchText(it) }, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .focusRequester(focusRequester), + placeholder = { Text(stringResource(R.string.search_connections)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (uiState.searchText.isNotEmpty()) { + IconButton(onClick = { viewModel.setSearchText("") }) { + Icon(Icons.Default.Clear, contentDescription = null) + } + } + }, + singleLine = true, ) } } - else -> { - val lazyListState = rememberLazyListState() - val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState) - LazyColumn( - modifier = Modifier - .fillMaxSize() - .nestedScroll(bounceBlockingConnection), - state = lazyListState, - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + when { + uiState.isLoading -> { + item(key = "connections_loading") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + + uiState.connections.isEmpty() -> { + item(key = "connections_empty") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.empty_connections), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + else -> { items( items = uiState.connections, key = { it.id }, @@ -445,6 +496,90 @@ fun ConnectionsScreen( } } } + } else { + Column(modifier = modifier.fillMaxSize()) { + AnimatedVisibility( + visible = uiState.isSearchActive, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + value = uiState.searchText, + onValueChange = { viewModel.setSearchText(it) }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + .focusRequester(focusRequester), + placeholder = { Text(stringResource(R.string.search_connections)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (uiState.searchText.isNotEmpty()) { + IconButton(onClick = { viewModel.setSearchText("") }) { + Icon(Icons.Default.Clear, contentDescription = null) + } + } + }, + singleLine = true, + ) + } + + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + uiState.connections.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.empty_connections), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> { + val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState) + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .nestedScroll(bounceBlockingConnection), + state = lazyListState, + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + overscrollEffect = rememberOverscrollEffect(), + ) { + items( + items = uiState.connections, + key = { it.id }, + ) { connection -> + ConnectionItem( + connection = connection, + onClick = { onConnectionClick(connection) }, + onClose = { viewModel.closeConnection(connection.id) }, + ) + } + } + } + } + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt index c7a3ae7..b04e96d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt @@ -23,7 +23,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandMore @@ -49,6 +49,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -70,6 +71,7 @@ import io.nekohasekai.sfa.compose.model.Group import io.nekohasekai.sfa.compose.model.GroupItem import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.utils.CommandClient @@ -80,6 +82,8 @@ fun GroupsCard( commandClient: CommandClient? = null, viewModel: GroupsViewModel? = null, showTopBar: Boolean = false, + listHeaderContent: (@Composable () -> Unit)? = null, + asSheet: Boolean = false, modifier: Modifier = Modifier, ) { val actualViewModel: GroupsViewModel = viewModel ?: viewModel( @@ -169,6 +173,8 @@ fun GroupsCard( onToggleExpanded = onToggleExpanded, onItemSelected = onItemSelected, onUrlTest = onUrlTest, + listHeaderContent = listHeaderContent, + asSheet = asSheet, modifier = modifier, ) } @@ -179,51 +185,78 @@ private fun GroupsCardContent( onToggleExpanded: (String) -> Unit, onItemSelected: (String, String) -> Unit, onUrlTest: (String) -> Unit, + listHeaderContent: (@Composable () -> Unit)? = null, + asSheet: Boolean = false, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.fillMaxWidth()) { - // Groups content - if (uiState.isLoading) { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } else if (uiState.groups.isEmpty()) { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(100.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "No groups available", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + val lazyListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } + val scrollModifier = + if (asSheet) { + rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier { + lazyListState.firstVisibleItemIndex == 0 && + lazyListState.firstVisibleItemScrollOffset == 0 } } else { - val lazyListState = rememberLazyListState() - val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState) - LazyColumn( - modifier = Modifier - .fillMaxSize() - .nestedScroll(bounceBlockingConnection), - state = lazyListState, - contentPadding = - PaddingValues( - start = 16.dp, - end = 16.dp, - top = 8.dp, - bottom = 16.dp, - ), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + Modifier.nestedScroll(rememberBounceBlockingNestedScrollConnection(lazyListState)) + } + val overscrollEffect = if (asSheet) null else rememberOverscrollEffect() + + LazyColumn( + modifier = + modifier + .fillMaxSize() + .then(scrollModifier), + state = lazyListState, + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + overscrollEffect = overscrollEffect, + ) { + if (listHeaderContent != null) { + item(key = "groups_list_header") { + listHeaderContent() + } + } + + when { + uiState.isLoading -> { + item(key = "groups_loading") { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + + uiState.groups.isEmpty() -> { + item(key = "groups_empty") { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(100.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No groups available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + else -> { items( items = uiState.groups, key = { it.tag }, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/SheetNestedScroll.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/SheetNestedScroll.kt new file mode 100644 index 0000000..3be3768 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/SheetNestedScroll.kt @@ -0,0 +1,58 @@ +package io.nekohasekai.sfa.compose.util + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Velocity + +@Composable +fun rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier(isAtTop: () -> Boolean): Modifier { + val isAtTopState = rememberUpdatedState(isAtTop) + val gestureStartedAtTop = remember { mutableStateOf(true) } + + val nestedScrollConnection = + remember { + object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + if (source != NestedScrollSource.UserInput) return Offset.Zero + val startedAtTop = gestureStartedAtTop.value + return when { + available.y < 0 -> available + available.y > 0 && !startedAtTop -> available + else -> Offset.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val startedAtTop = gestureStartedAtTop.value + return when { + available.y < 0 -> available + available.y > 0 && !startedAtTop -> available + else -> Velocity.Zero + } + } + } + } + + val gestureGateModifier = + Modifier.pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + gestureStartedAtTop.value = isAtTopState.value.invoke() + do { + val event = awaitPointerEvent() + } while (event.changes.any { it.pressed }) + } + } + + return gestureGateModifier.nestedScroll(nestedScrollConnection) +}