Unify sheets swipe-to-dismiss gating

This commit is contained in:
世界
2026-02-05 16:44:36 +08:00
parent 84dfd82ab8
commit 084317deac
5 changed files with 384 additions and 145 deletions

View File

@@ -1014,14 +1014,19 @@ class MainActivity :
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.9f), .fillMaxHeight(),
) { ) {
// Header // Groups content
GroupsCard(
serviceStatus = currentServiceStatus,
commandClient = dashboardViewModel.commandClient,
viewModel = groupsViewModel,
listHeaderContent = {
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp) .padding(horizontal = 8.dp),
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -1048,12 +1053,8 @@ class MainActivity :
} }
} }
} }
},
// Groups content asSheet = true,
GroupsCard(
serviceStatus = currentServiceStatus,
commandClient = dashboardViewModel.commandClient,
viewModel = groupsViewModel,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -1097,7 +1098,7 @@ class MainActivity :
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.9f), .fillMaxHeight(),
) { ) {
if (displayConnection != null) { if (displayConnection != null) {
ConnectionDetailsScreen( ConnectionDetailsScreen(
@@ -1106,11 +1107,13 @@ class MainActivity :
onClose = { onClose = {
selectedConnectionId?.let { connectionsViewModel.closeConnection(it) } selectedConnectionId?.let { connectionsViewModel.closeConnection(it) }
}, },
asSheet = true,
) )
} else { } else {
ConnectionsPage( ConnectionsPage(
serviceStatus = currentServiceStatus, serviceStatus = currentServiceStatus,
viewModel = connectionsViewModel, viewModel = connectionsViewModel,
asSheet = true,
showTitle = true, showTitle = true,
onConnectionClick = { selectedConnectionId = it }, onConnectionClick = { selectedConnectionId = it },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -46,6 +47,7 @@ import androidx.compose.ui.unit.dp
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.model.Connection import io.nekohasekai.sfa.compose.model.Connection
import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -57,17 +59,25 @@ fun ConnectionDetailsScreen(
onClose: () -> Unit, onClose: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showHeader: Boolean = true, showHeader: Boolean = true,
asSheet: Boolean = false,
) { ) {
val dateTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) val dateTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(scrollState) val scrollModifier =
if (asSheet) {
rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier {
scrollState.value == 0
}
} else {
Modifier.nestedScroll(rememberBounceBlockingNestedScrollConnection(scrollState))
}
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(bounceBlockingConnection) .then(scrollModifier)
.verticalScroll(scrollState), .verticalScroll(scrollState, overscrollEffect = if (asSheet) null else rememberOverscrollEffect()),
) { ) {
if (showHeader) { if (showHeader) {
Row( Row(

View File

@@ -17,7 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.ConnectionSort
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -69,6 +71,7 @@ import io.nekohasekai.sfa.constant.Status
fun ConnectionsPage( fun ConnectionsPage(
serviceStatus: Status, serviceStatus: Status,
viewModel: ConnectionsViewModel = viewModel(), viewModel: ConnectionsViewModel = viewModel(),
asSheet: Boolean = false,
showTitle: Boolean = true, showTitle: Boolean = true,
showTopBar: Boolean = false, showTopBar: Boolean = false,
onConnectionClick: (String) -> Unit = {}, onConnectionClick: (String) -> Unit = {},
@@ -87,14 +90,21 @@ fun ConnectionsPage(
} }
} }
Column( val headerRowModifier =
modifier = modifier.fillMaxSize(), if (asSheet) {
) { Modifier
Row( .fillMaxWidth()
modifier = Modifier .padding(bottom = 16.dp)
} else {
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(bottom = 16.dp), .padding(bottom = 16.dp)
}
val headerContent: @Composable () -> Unit = {
Row(
modifier = headerRowModifier,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -258,7 +268,22 @@ fun ConnectionsPage(
} }
} }
} }
}
if (asSheet) {
ConnectionsScreen(
serviceStatus = serviceStatus,
viewModel = viewModel,
onConnectionClick = { connection -> onConnectionClick(connection.id) },
listHeaderContent = headerContent,
asSheet = true,
modifier = modifier.fillMaxSize(),
)
} else {
Column(
modifier = modifier.fillMaxSize(),
) {
headerContent()
ConnectionsScreen( ConnectionsScreen(
serviceStatus = serviceStatus, serviceStatus = serviceStatus,
viewModel = viewModel, viewModel = viewModel,
@@ -266,6 +291,7 @@ fun ConnectionsPage(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -347,6 +373,8 @@ fun ConnectionsScreen(
serviceStatus: Status, serviceStatus: Status,
viewModel: ConnectionsViewModel = viewModel(), viewModel: ConnectionsViewModel = viewModel(),
onConnectionClick: (Connection) -> Unit = {}, onConnectionClick: (Connection) -> Unit = {},
listHeaderContent: (@Composable () -> Unit)? = null,
asSheet: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
@@ -365,6 +393,110 @@ fun ConnectionsScreen(
viewModel.updateServiceStatus(serviceStatus) viewModel.updateServiceStatus(serviceStatus)
} }
val lazyListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
if (asSheet) {
val sheetSwipeToDismissModifier =
rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier {
lazyListState.firstVisibleItemIndex == 0 &&
lazyListState.firstVisibleItemScrollOffset == 0
}
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()
}
}
item(key = "connections_search") {
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(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 -> {
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 },
) { connection ->
ConnectionItem(
connection = connection,
onClick = { onConnectionClick(connection) },
onClose = { viewModel.closeConnection(connection.id) },
)
}
}
}
}
} else {
Column(modifier = modifier.fillMaxSize()) { Column(modifier = modifier.fillMaxSize()) {
AnimatedVisibility( AnimatedVisibility(
visible = uiState.isSearchActive, visible = uiState.isSearchActive,
@@ -380,7 +512,8 @@ fun ConnectionsScreen(
OutlinedTextField( OutlinedTextField(
value = uiState.searchText, value = uiState.searchText,
onValueChange = { viewModel.setSearchText(it) }, onValueChange = { viewModel.setSearchText(it) },
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(bottom = 8.dp) .padding(bottom = 8.dp)
@@ -422,15 +555,16 @@ fun ConnectionsScreen(
} }
else -> { else -> {
val lazyListState = rememberLazyListState()
val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState) val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState)
LazyColumn( LazyColumn(
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(bounceBlockingConnection), .nestedScroll(bounceBlockingConnection),
state = lazyListState, state = lazyListState,
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
overscrollEffect = rememberOverscrollEffect(),
) { ) {
items( items(
items = uiState.connections, items = uiState.connections,
@@ -446,6 +580,7 @@ fun ConnectionsScreen(
} }
} }
} }
}
} }
@Composable @Composable

View File

@@ -23,7 +23,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore 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.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset 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.model.GroupItem
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.utils.CommandClient import io.nekohasekai.sfa.utils.CommandClient
@@ -80,6 +82,8 @@ fun GroupsCard(
commandClient: CommandClient? = null, commandClient: CommandClient? = null,
viewModel: GroupsViewModel? = null, viewModel: GroupsViewModel? = null,
showTopBar: Boolean = false, showTopBar: Boolean = false,
listHeaderContent: (@Composable () -> Unit)? = null,
asSheet: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val actualViewModel: GroupsViewModel = viewModel ?: viewModel( val actualViewModel: GroupsViewModel = viewModel ?: viewModel(
@@ -169,6 +173,8 @@ fun GroupsCard(
onToggleExpanded = onToggleExpanded, onToggleExpanded = onToggleExpanded,
onItemSelected = onItemSelected, onItemSelected = onItemSelected,
onUrlTest = onUrlTest, onUrlTest = onUrlTest,
listHeaderContent = listHeaderContent,
asSheet = asSheet,
modifier = modifier, modifier = modifier,
) )
} }
@@ -179,11 +185,47 @@ private fun GroupsCardContent(
onToggleExpanded: (String) -> Unit, onToggleExpanded: (String) -> Unit,
onItemSelected: (String, String) -> Unit, onItemSelected: (String, String) -> Unit,
onUrlTest: (String) -> Unit, onUrlTest: (String) -> Unit,
listHeaderContent: (@Composable () -> Unit)? = null,
asSheet: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier.fillMaxWidth()) { val lazyListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
// Groups content val scrollModifier =
if (uiState.isLoading) { if (asSheet) {
rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier {
lazyListState.firstVisibleItemIndex == 0 &&
lazyListState.firstVisibleItemScrollOffset == 0
}
} else {
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( Box(
modifier = modifier =
Modifier Modifier
@@ -193,7 +235,11 @@ private fun GroupsCardContent(
) { ) {
CircularProgressIndicator() CircularProgressIndicator()
} }
} else if (uiState.groups.isEmpty()) { }
}
uiState.groups.isEmpty() -> {
item(key = "groups_empty") {
Box( Box(
modifier = modifier =
Modifier Modifier
@@ -207,23 +253,10 @@ private fun GroupsCardContent(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} else { }
val lazyListState = rememberLazyListState() }
val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState)
LazyColumn( else -> {
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),
) {
items( items(
items = uiState.groups, items = uiState.groups,
key = { it.tag }, key = { it.tag },

View File

@@ -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)
}