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:
rasa68andrasa68 authored and GitHub committed 2026-10-22 09:48:09 +08:00
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
)
}
}
}
}