From 3993a62f66effb35a9691a7c6aed27c5e53f2b17 Mon Sep 17 00:00:00 2001 From: Goooler Date: Thu, 14 May 2026 16:28:14 +0800 Subject: [PATCH] Add extension overloads for LazyListScope --- .../kr328/clash/proxy/ui/ProxyScreen.kt | 77 ++++++++++++++++--- .../kr328/clash/ui/component/TabbyScaffold.kt | 9 ++- .../kr328/clash/ui/icon/BaselineArrowUp.kt | 43 +++++++++++ .../com/github/kr328/clash/ui/theme/Theme.kt | 4 + 4 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 ui/src/main/kotlin/com/github/kr328/clash/ui/icon/BaselineArrowUp.kt diff --git a/ui/proxy/src/main/kotlin/com/github/kr328/clash/proxy/ui/ProxyScreen.kt b/ui/proxy/src/main/kotlin/com/github/kr328/clash/proxy/ui/ProxyScreen.kt index 3288e7a486..861553e27c 100644 --- a/ui/proxy/src/main/kotlin/com/github/kr328/clash/proxy/ui/ProxyScreen.kt +++ b/ui/proxy/src/main/kotlin/com/github/kr328/clash/proxy/ui/ProxyScreen.kt @@ -14,14 +14,18 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -35,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -61,6 +66,7 @@ import com.github.kr328.clash.proxy.vm.ProxyViewModel import com.github.kr328.clash.proxy.vm.ProxyViewModel.SelectedProxy import com.github.kr328.clash.ui.component.Spacer import com.github.kr328.clash.ui.component.TabbyScaffold +import com.github.kr328.clash.ui.icon.BaselineArrowUp import com.github.kr328.clash.ui.icon.BaselineFlashOn import com.github.kr328.clash.ui.icon.BaselineMoreVert import com.github.kr328.clash.ui.icon.TabbyIcons @@ -126,6 +132,45 @@ private fun ProxyContent( var menuVisible by remember { mutableStateOf(false) } val currentGroup = uiState.groups.getOrNull(uiState.currentPage) val showUrlTestAction = uiState.groupNames.isNotEmpty() + val groupNames = uiState.groupNames + val pagerState = + if (groupNames.isNotEmpty()) { + val initialPage = uiState.initialPage.coerceIn(groupNames.indices) + rememberPagerState(initialPage = initialPage, pageCount = { groupNames.size }) + } else { + null + } + val scope = rememberCoroutineScope() + val gridStates = + if (groupNames.isNotEmpty()) { + List(groupNames.size) { rememberLazyGridState() } + } else { + emptyList() + } + + pagerState?.let { validPagerState -> + LaunchedEffect(validPagerState) { + snapshotFlow { validPagerState.currentPage }.collect(onPageChanged) + } + + val currentPage = uiState.currentPage.coerceIn(groupNames.indices) + LaunchedEffect(currentPage) { + if (currentPage != validPagerState.currentPage) validPagerState.scrollToPage(currentPage) + } + } + + val firstRowSize = columnsForProxyLine(uiState.proxyLine) + val showScrollToTopFab by + remember(pagerState, gridStates, firstRowSize) { + derivedStateOf { + val validPagerState = pagerState ?: return@derivedStateOf false + val currentGridState = + gridStates.getOrNull(validPagerState.currentPage) ?: return@derivedStateOf false + + !currentGridState.isScrollInProgress && + currentGridState.firstVisibleItemIndex >= firstRowSize + } + } if (menuVisible) { ModalBottomSheet( @@ -185,6 +230,20 @@ private fun ProxyContent( ) } }, + floatingActionButton = { + if (showScrollToTopFab) { + FloatingActionButton( + onClick = { + val validPagerState = pagerState ?: return@FloatingActionButton + val currentGridState = + gridStates.getOrNull(validPagerState.currentPage) ?: return@FloatingActionButton + scope.launch { currentGridState.animateScrollToItem(0) } + } + ) { + Icon(imageVector = TabbyIcons.BaselineArrowUp, contentDescription = null) + } + } + }, ) { innerPadding -> Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { if (uiState.groupNames.isEmpty()) { @@ -198,7 +257,8 @@ private fun ProxyContent( ProxyPagerContent( uiState = uiState, selectedProxies = selectedProxies, - onPageChanged = onPageChanged, + pagerState = pagerState ?: return@Box, + gridStates = gridStates, onProxySelected = onProxySelected, ) } @@ -210,23 +270,15 @@ private fun ProxyContent( private fun ProxyPagerContent( uiState: ProxyViewModel.UiState, selectedProxies: List, - onPageChanged: (Int) -> Unit, + pagerState: PagerState, + gridStates: List, onProxySelected: (Int, String) -> Unit, ) { val groupNames = uiState.groupNames if (groupNames.isEmpty()) return - val initialPage = uiState.initialPage.coerceIn(groupNames.indices) - val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { groupNames.size }) val scope = rememberCoroutineScope() - LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage }.collect(onPageChanged) } - - val currentPage = uiState.currentPage.coerceIn(groupNames.indices) - LaunchedEffect(currentPage) { - if (currentPage != pagerState.currentPage) pagerState.scrollToPage(currentPage) - } - Column(modifier = Modifier.fillMaxSize()) { PrimaryScrollableTabRow(selectedTabIndex = pagerState.currentPage, edgePadding = 0.dp) { groupNames.forEachIndexed { index, name -> @@ -245,6 +297,7 @@ private fun ProxyPagerContent( index = page, proxyLine = uiState.proxyLine, group = uiState.groups.getOrNull(page) ?: ProxyViewModel.UiState.ProxyGroupUiState(), + gridState = gridStates.getOrElse(page) { rememberLazyGridState() }, selectedProxies = selectedProxies, onProxySelected = onProxySelected, ) @@ -257,6 +310,7 @@ private fun ProxyGroupPage( index: Int, proxyLine: Int, group: ProxyViewModel.UiState.ProxyGroupUiState, + gridState: LazyGridState, selectedProxies: List, onProxySelected: (Int, String) -> Unit, ) { @@ -268,6 +322,7 @@ private fun ProxyGroupPage( val unselectedBackground = MaterialTheme.colorScheme.surface LazyVerticalGrid( + state = gridState, columns = GridCells.Fixed(columnsForProxyLine(proxyLine)), modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(gridContentPadding), diff --git a/ui/src/main/kotlin/com/github/kr328/clash/ui/component/TabbyScaffold.kt b/ui/src/main/kotlin/com/github/kr328/clash/ui/component/TabbyScaffold.kt index ba23e1374e..1d814c68e5 100644 --- a/ui/src/main/kotlin/com/github/kr328/clash/ui/component/TabbyScaffold.kt +++ b/ui/src/main/kotlin/com/github/kr328/clash/ui/component/TabbyScaffold.kt @@ -48,7 +48,14 @@ fun TabbyScaffold( snackbarHost: @Composable () -> Unit = { snackbarHostState?.let { SnackbarHost(hostState = it) } }, + floatingActionButton: @Composable () -> Unit = {}, content: @Composable (PaddingValues) -> Unit, ) { - Scaffold(modifier = modifier, topBar = topBar, snackbarHost = snackbarHost, content = content) + Scaffold( + modifier = modifier, + topBar = topBar, + snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + content = content, + ) } diff --git a/ui/src/main/kotlin/com/github/kr328/clash/ui/icon/BaselineArrowUp.kt b/ui/src/main/kotlin/com/github/kr328/clash/ui/icon/BaselineArrowUp.kt new file mode 100644 index 0000000000..da283ed9dc --- /dev/null +++ b/ui/src/main/kotlin/com/github/kr328/clash/ui/icon/BaselineArrowUp.kt @@ -0,0 +1,43 @@ +package com.github.kr328.clash.ui.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +@Suppress("UnusedReceiverParameter") +val TabbyIcons.BaselineArrowUp: ImageVector + get() { + if (_BaselineArrowUp != null) { + return _BaselineArrowUp!! + } + _BaselineArrowUp = + ImageVector.Builder( + name = "arrow-up", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 16f, + viewportHeight = 16f, + ) + .apply { + path(fill = SolidColor(Color.White)) { + moveTo(8f, 15f) + arcToRelative(0.5f, 0.5f, 0f, false, false, 0.5f, -0.5f) + verticalLineTo(2.707f) + lineToRelative(3.146f, 3.147f) + arcToRelative(0.5f, 0.5f, 0f, false, false, 0.708f, -0.708f) + lineToRelative(-4f, -4f) + arcToRelative(0.5f, 0.5f, 0f, false, false, -0.708f, 0f) + lineToRelative(-4f, 4f) + arcToRelative(0.5f, 0.5f, 0f, true, false, 0.708f, 0.708f) + lineTo(7.5f, 2.707f) + verticalLineTo(14.5f) + arcToRelative(0.5f, 0.5f, 0f, false, false, 0.5f, 0.5f) + } + } + .build() + return _BaselineArrowUp!! + } + +@Suppress("ObjectPropertyName") private var _BaselineArrowUp: ImageVector? = null diff --git a/ui/src/main/kotlin/com/github/kr328/clash/ui/theme/Theme.kt b/ui/src/main/kotlin/com/github/kr328/clash/ui/theme/Theme.kt index 12d58a7ab3..511cf95b14 100644 --- a/ui/src/main/kotlin/com/github/kr328/clash/ui/theme/Theme.kt +++ b/ui/src/main/kotlin/com/github/kr328/clash/ui/theme/Theme.kt @@ -20,6 +20,8 @@ private val DarkColorScheme = darkColorScheme( primary = TabbyDarkPrimary, onPrimary = TabbyOnPrimary, + primaryContainer = TabbyDarkPrimary, + onPrimaryContainer = TabbyOnPrimary, secondary = TabbyDarkPrimary, onSecondary = TabbyOnPrimary, background = TabbyDarkBackground, @@ -45,6 +47,8 @@ private val LightColorScheme = lightColorScheme( primary = TabbyLightPrimary, onPrimary = TabbyOnPrimary, + primaryContainer = TabbyLightPrimary, + onPrimaryContainer = TabbyOnPrimary, secondary = TabbyLightPrimary, onSecondary = TabbyOnPrimary, background = TabbyLightBackground,