mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-29 06:31:54 +00:00
Redesign subscription group tabs as a floating pill bar with count badges (#6336)
* Redesign subscription group tabs as a floating pill bar with count badges * Group tabs: dark-mode bar tone, equal-width tabs when they fit, room around selected tab --------- Co-authored-by: rasa68 <noreply@example.com>
This commit is contained in:
1 parent
59ff87606f
commit
eedb287cc9
1 file changed
+179
-43
@@ -1,26 +1,68 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRowDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.dto.GroupMapItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
private val TAB_BAR_PADDING = 3.dp
|
||||
private val TAB_SPACING = 2.dp
|
||||
private val TAB_HEIGHT = 30.dp
|
||||
private val SELECTED_TAB_MARGIN = 12.dp
|
||||
|
||||
/**
|
||||
* Subscription group tabs: a floating rounded bar whose selected tab is a tinted pill and whose
|
||||
* server count is a small badge.
|
||||
*
|
||||
* While all tabs fit, they share the bar width equally (like Telegram's folder tabs); once they
|
||||
* no longer fit, they keep their natural width and the bar scrolls horizontally, keeping the
|
||||
* selected tab in view.
|
||||
*/
|
||||
@Composable
|
||||
fun GroupTabBar(
|
||||
groups: List<GroupMapItem>,
|
||||
@@ -30,33 +72,91 @@ fun GroupTabBar(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val selectedIndex = selectedTabIndex.coerceIn(0, groups.lastIndex)
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = selectedIndex,
|
||||
val colors = MaterialTheme.colorScheme
|
||||
// The plain surface color equals the screen background in dark mode, so the bar needs its own tone there.
|
||||
val dark = colors.surface.luminance() < 0.5f
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
|
||||
edgePadding = 16.dp,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.SecondaryIndicator(
|
||||
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedIndex]),
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
shape = CircleShape,
|
||||
color = if (dark) colors.surfaceContainerHigh else colors.surface,
|
||||
border = BorderStroke(0.5.dp, colors.outlineVariant.copy(alpha = if (dark) 0.6f else 0.35f)),
|
||||
shadowElevation = 3.dp
|
||||
) {
|
||||
groups.forEachIndexed { index, group ->
|
||||
val serverFlow = remember(group.id, mainViewModel) {
|
||||
mainViewModel.serversForGroup(group.id)
|
||||
BoxWithConstraints {
|
||||
val barPaddingPx = with(LocalDensity.current) { TAB_BAR_PADDING.roundToPx() }
|
||||
val availablePx = constraints.maxWidth - 2 * barPaddingPx
|
||||
Row(
|
||||
Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(TAB_BAR_PADDING)
|
||||
) {
|
||||
EqualOrNaturalTabsLayout(availablePx = availablePx) {
|
||||
groups.forEachIndexed { index, group ->
|
||||
key(group.id) {
|
||||
val serverFlow = remember(group.id, mainViewModel) {
|
||||
mainViewModel.serversForGroup(group.id)
|
||||
}
|
||||
GroupTabItem(
|
||||
group = group,
|
||||
selected = index == selectedIndex,
|
||||
serverFlow = serverFlow,
|
||||
onClick = { onTabClick(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GroupTabItem(
|
||||
group = group,
|
||||
selected = index == selectedIndex,
|
||||
serverFlow = serverFlow,
|
||||
onClick = { onTabClick(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the tabs out in a row. If every tab fits in an equal share of [availablePx] they all get
|
||||
* that share; otherwise, if they still fit together, the spare space is spread evenly; otherwise
|
||||
* they keep their natural width (the parent scrolls).
|
||||
*/
|
||||
@Composable
|
||||
private fun EqualOrNaturalTabsLayout(
|
||||
availablePx: Int,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Layout(content = content) { measurables, _ ->
|
||||
if (measurables.isEmpty()) return@Layout layout(0, 0) {}
|
||||
|
||||
val spacing = TAB_SPACING.roundToPx()
|
||||
val tabHeight = TAB_HEIGHT.roundToPx()
|
||||
val count = measurables.size
|
||||
val gaps = spacing * (count - 1)
|
||||
val natural = measurables.map { it.maxIntrinsicWidth(tabHeight) }
|
||||
val equalShare = (availablePx - gaps) / count
|
||||
|
||||
val widths = when {
|
||||
availablePx <= 0 -> natural
|
||||
natural.all { it <= equalShare } -> List(count) { equalShare }
|
||||
natural.sum() + gaps <= availablePx -> {
|
||||
val extra = (availablePx - gaps - natural.sum()) / count
|
||||
natural.map { it + extra }
|
||||
}
|
||||
else -> natural
|
||||
}
|
||||
|
||||
val placeables = measurables.mapIndexed { i, measurable ->
|
||||
measurable.measure(Constraints.fixed(widths[i], tabHeight))
|
||||
}
|
||||
layout(widths.sum() + gaps, tabHeight) {
|
||||
var x = 0
|
||||
placeables.forEach { placeable ->
|
||||
placeable.placeRelative(x, 0)
|
||||
x += placeable.width + spacing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun GroupTabItem(
|
||||
group: GroupMapItem,
|
||||
@@ -65,25 +165,61 @@ private fun GroupTabItem(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val servers by serverFlow.collectAsStateWithLifecycle()
|
||||
val text = if (group.id.isEmpty()) {
|
||||
group.remarks
|
||||
} else {
|
||||
"${group.remarks} (${servers.size})"
|
||||
}
|
||||
// The default group (empty id) never showed a count.
|
||||
val count = if (group.id.isEmpty()) 0 else servers.size
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val bringIntoView = remember { BringIntoViewRequester() }
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
val marginPx = with(LocalDensity.current) { SELECTED_TAB_MARGIN.toPx() }
|
||||
|
||||
Tab(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.widthIn(min = 56.dp)
|
||||
.heightIn(min = 48.dp),
|
||||
text = {
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
// Bring the tab in with some room around it so the pill never ends up flush against the bar's
|
||||
// rounded edge; at the first and last tab the scroll stops at the bar's own inner padding.
|
||||
LaunchedEffect(selected, size) {
|
||||
if (selected && size != IntSize.Zero) {
|
||||
bringIntoView.bringIntoView(
|
||||
Rect(-marginPx, 0f, size.width + marginPx, size.height.toFloat())
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// One selectable node: name, count and selected state are announced together.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.bringIntoViewRequester(bringIntoView)
|
||||
.onSizeChanged { size = it }
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) colors.primary.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.selectable(selected = selected, role = Role.Tab, onClick = onClick)
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = group.remarks,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (selected) colors.primary else colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (count > 0) {
|
||||
Spacer(Modifier.width(3.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 16.dp, minHeight = 16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) colors.primary else colors.onSurfaceVariant.copy(alpha = 0.55f))
|
||||
.padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp),
|
||||
color = if (selected) colors.onPrimary else colors.surface,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user