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,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(),

View File

@@ -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(

View File

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

View File

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

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