ui: scroll the library tab strip and explain offline mode

The tab bar measured the selected tab's full label before the trailing
controls, so a long translation or a large font scale squeezed the
settings gear and eventually removed it, and an indicator in the bar
made that happen sooner. Put the tabs in a weighted, horizontally
scrollable strip and keep the gear outside it, then scroll the selected
tab into view when the pager settles, in either layout direction.

Move the offline indicator out of the bar into a strip above the tabs
that names the mode and offers to go online. Offline mode limits every
list, and Home already swaps to downloaded content without saying why;
an icon in the bar could not explain that and competed for the same
width.
This commit is contained in:
austinried
2026-10-21 10:14:10 +00:00
parent aca2bd4be2
commit 47787182d0
3 changed files with 314 additions and 60 deletions
@@ -6,6 +6,7 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize 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.heightIn
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -25,6 +27,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -47,6 +50,7 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
@@ -94,12 +98,14 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.PagingData import androidx.paging.PagingData
@@ -586,14 +592,15 @@ fun LibraryScreen(
.onSizeChanged { tabBarHeightPx = it.height.toFloat() } .onSizeChanged { tabBarHeightPx = it.height.toFloat() }
.padding(bottom = bottomInset), .padding(bottom = bottomInset),
) { ) {
if (offline) {
OfflineStrip(onGoOnline = onExitOffline)
}
LibraryTabs( LibraryTabs(
pagerState = pagerState, pagerState = pagerState,
onTabSelected = { tab -> onTabSelected = { tab ->
if (tab == selectedTab) topRequest++ else onTabSelected(tab) if (tab == selectedTab) topRequest++ else onTabSelected(tab)
}, },
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
offline = offline,
onExitOffline = onExitOffline,
artwork = artwork, artwork = artwork,
syncing = syncing, syncing = syncing,
) )
@@ -856,13 +863,12 @@ internal fun LibraryTabs(
pagerState: PagerState, pagerState: PagerState,
onTabSelected: (LibraryTab) -> Unit, onTabSelected: (LibraryTab) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
offline: Boolean = false,
onExitOffline: () -> Unit = {},
artwork: ArtworkColors?, artwork: ArtworkColors?,
syncing: Boolean = false, syncing: Boolean = false,
) { ) {
val indicatorStretch = 18.dp val indicatorStretch = 18.dp
val density = LocalDensity.current val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val bounds = remember { mutableStateMapOf<Int, Rect>() } val bounds = remember { mutableStateMapOf<Int, Rect>() }
val page = pagerState.currentPage val page = pagerState.currentPage
val fraction = pagerState.currentPageOffsetFraction val fraction = pagerState.currentPageOffsetFraction
@@ -894,6 +900,28 @@ internal fun LibraryTabs(
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() } val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
val scrollState = rememberScrollState()
var viewportWidth by remember { mutableIntStateOf(0) }
var contentWidth by remember { mutableIntStateOf(0) }
var revealed by remember { mutableStateOf(false) }
LaunchedEffect(pagerState.settledPage, viewportWidth, contentWidth, layoutDirection, bounds[pagerState.settledPage]) {
if (viewportWidth == 0) return@LaunchedEffect
val settled = bounds[pagerState.settledPage] ?: return@LaunchedEffect
val start = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.right else settled.left
val end = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.left else settled.right
val target =
when {
start < scrollState.value -> start
end > scrollState.value + viewportWidth -> end - viewportWidth
else -> null
}
if (target != null) {
val offset = target.roundToInt().coerceIn(0, scrollState.maxValue)
if (revealed) scrollState.animateScrollTo(offset) else scrollState.scrollTo(offset)
}
revealed = true
}
Box(Modifier.fillMaxWidth()) { Box(Modifier.fillMaxWidth()) {
Box( Box(
modifier = modifier =
@@ -902,71 +930,125 @@ internal fun LibraryTabs(
.background(headerColor) .background(headerColor)
.padding(horizontal = 8.dp, vertical = 2.dp), .padding(horizontal = 8.dp, vertical = 2.dp),
) { ) {
Box( Row(
modifier = modifier = Modifier.fillMaxWidth(),
Modifier.drawBehind { verticalAlignment = Alignment.CenterVertically,
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) { horizontalArrangement = Arrangement.spacedBy(4.dp),
val height = tabContentHeightPx.coerceAtMost(current.height)
drawRoundRect(
color = indicatorColor,
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
size = Size(indicatorRight - indicatorLeft, height),
cornerRadius = CornerRadius(8.dp.toPx()),
)
}
},
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier =
Modifier
.weight(1f)
.onSizeChanged { viewportWidth = it.width }
.horizontalScroll(scrollState),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
LibraryTab.entries.forEachIndexed { index, tab -> Box(
TabButton( modifier =
tab = tab, Modifier
artwork = artwork, .onSizeChanged { contentWidth = it.width }
progress = .drawBehind {
when (index) { if (current != null &&
page -> 1f - transition indicatorLeft != null &&
page + (if (fraction >= 0f) 1 else -1) -> transition indicatorRight != null &&
else -> 0f indicatorRight > indicatorLeft
) {
val height = tabContentHeightPx.coerceAtMost(current.height)
drawRoundRect(
color = indicatorColor,
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
size = Size(indicatorRight - indicatorLeft, height),
cornerRadius = CornerRadius(8.dp.toPx()),
)
}
}, },
indicatorLeft = indicatorLeft, ) {
indicatorRight = indicatorRight, Row(
onClick = { onTabSelected(tab) }, verticalAlignment = Alignment.CenterVertically,
modifier = horizontalArrangement = Arrangement.spacedBy(4.dp),
Modifier.onGloballyPositioned { coordinates -> ) {
bounds[index] = coordinates.boundsInParent() LibraryTab.entries.forEachIndexed { index, tab ->
}, TabButton(
) tab = tab,
} artwork = artwork,
Spacer(Modifier.weight(1f)) progress =
if (offline) { when (index) {
IconButton(onClick = onExitOffline) { page -> 1f - transition
Icon( page + (if (fraction >= 0f) 1 else -1) -> transition
imageVector = Icons.Rounded.CloudOff, else -> 0f
contentDescription = stringResource(R.string.offline_mode_status), },
tint = MaterialTheme.colorScheme.onBackground, indicatorLeft = indicatorLeft,
) indicatorRight = indicatorRight,
} onClick = { onTabSelected(tab) },
} modifier =
IconButton(onClick = onOpenSettings) { Modifier.onGloballyPositioned { coordinates ->
Box(contentAlignment = Alignment.Center) { bounds[index] = coordinates.boundsInParent()
Icon( },
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.navigation_tabs_settings),
tint = MaterialTheme.colorScheme.onBackground,
)
if (syncing) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
) )
} }
} }
} }
} }
IconButton(onClick = onOpenSettings) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.navigation_tabs_settings),
tint = MaterialTheme.colorScheme.onBackground,
)
if (syncing) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
@Composable
private fun OfflineStrip(
onGoOnline: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier.fillMaxWidth()) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Surface(
onClick = onGoOnline,
color = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 44.dp)
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Rounded.CloudOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Text(
text = stringResource(R.string.offline_mode_banner),
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.offline_go_online),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
} }
} }
} }
+2 -1
View File
@@ -175,7 +175,8 @@
<string name="filters">Filters</string> <string name="filters">Filters</string>
<string name="filter_downloaded">Downloaded</string> <string name="filter_downloaded">Downloaded</string>
<string name="close_search">Close search</string> <string name="close_search">Close search</string>
<string name="offline_mode_status">Offline mode; tap to go online</string> <string name="offline_mode_banner">Offline mode — downloads only</string>
<string name="offline_go_online">Go online</string>
<string name="shuffle_artist">Shuffle artist</string> <string name="shuffle_artist">Shuffle artist</string>
<string name="status_playing">Playing</string> <string name="status_playing">Playing</string>
@@ -2,8 +2,10 @@ package com.subtracks.ui
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.test.assertWidthIsEqualTo
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithTag
@@ -16,6 +18,9 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.test.swipeRight import androidx.compose.ui.test.swipeRight
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.LoadStates import androidx.paging.LoadStates
import androidx.paging.PagingData import androidx.paging.PagingData
@@ -342,6 +347,170 @@ class LibraryScreenTest {
assertTrue("the tab row should reflow in one direction, but the icons moved $samples", monotonic) assertTrue("the tab row should reflow in one direction, but the icons moved $samples", monotonic)
} }
@Test
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
fun aLongTabKeepsTheGearFullSizeAndScrollsIntoView() {
var labelWidth = 0f
composeRule.setContent {
SubtracksTheme {
labelWidth =
rememberTextMeasurer()
.measure(
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
softWrap = false,
).size.width
.toFloat()
LibraryTabs(
pagerState =
remember {
PagerState(
currentPage = LibraryTab.Playlists.ordinal,
pageCount = { LibraryTab.entries.size },
)
},
onTabSelected = {},
onOpenSettings = {},
artwork = null,
)
}
}
composeRule.waitForIdle()
composeRule
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
.assertWidthIsEqualTo(40.dp)
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
}
@Test
@Config(qualifiers = "ar-rEG-w320dp-h640dp-xhdpi")
fun aLongTabScrollsIntoViewInRtl() {
var labelWidth = 0f
composeRule.setContent {
SubtracksTheme {
labelWidth =
rememberTextMeasurer()
.measure(
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
softWrap = false,
).size.width
.toFloat()
LibraryTabs(
pagerState =
remember {
PagerState(
currentPage = LibraryTab.Playlists.ordinal,
pageCount = { LibraryTab.entries.size },
)
},
onTabSelected = {},
onOpenSettings = {},
artwork = null,
)
}
}
composeRule.waitForIdle()
composeRule
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
.assertWidthIsEqualTo(40.dp)
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
}
@Test
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
fun movingToALongTabScrollsItIntoView() {
val selectedTab = mutableStateOf(LibraryTab.Home)
var labelWidth = 0f
composeRule.setContent {
SubtracksTheme {
labelWidth =
rememberTextMeasurer()
.measure(
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
softWrap = false,
).size.width
.toFloat()
LibraryScreen(
selectedTab = selectedTab.value,
onTabSelected = { selectedTab.value = it },
albums = remember { flowOf(PagingData.empty<Album>()) },
artists = remember { flowOf(PagingData.empty<Artist>()) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
coverArt = { _, _ -> null },
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
)
}
}
composeRule.waitForIdle()
composeRule.runOnIdle { selectedTab.value = LibraryTab.Playlists }
val expected = with(composeRule.density) { TAB_LABEL_CHROME.toPx() } + labelWidth
composeRule.waitUntil(5_000) { tabBounds(LibraryTab.Playlists).width >= expected - 1f }
composeRule
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
.assertWidthIsEqualTo(40.dp)
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
}
private fun assertTabFullyVisible(
tab: LibraryTab,
labelWidth: Float,
) {
val bounds = tabBounds(tab)
val expected = with(composeRule.density) { TAB_LABEL_CHROME.toPx() } + labelWidth
assertEquals("the selected tab should be shown in full, but was $bounds", expected, bounds.width, 1f)
}
@Test
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
fun theOfflineStripExplainsTheModeAndGoesOnline() {
var exited = false
composeRule.setContent {
SubtracksTheme {
LibraryScreen(
selectedTab = LibraryTab.Playlists,
onTabSelected = {},
albums = remember { flowOf(PagingData.empty<Album>()) },
artists = remember { flowOf(PagingData.empty<Artist>()) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
coverArt = { _, _ -> null },
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
offline = true,
onExitOffline = { exited = true },
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithText(composeRule.activity.getString(R.string.offline_mode_banner)).performClick()
assertTrue("the offline strip should leave offline mode", exited)
composeRule
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
.assertWidthIsEqualTo(40.dp)
}
private fun tabBounds(tab: LibraryTab) =
composeRule
.onNodeWithContentDescription(composeRule.activity.getString(tab.label))
.fetchSemanticsNode()
.boundsInRoot
private val settledLoadStates = private val settledLoadStates =
LoadStates( LoadStates(
refresh = LoadState.NotLoading(endOfPaginationReached = true), refresh = LoadState.NotLoading(endOfPaginationReached = true),
@@ -378,3 +547,5 @@ class LibraryScreenTest {
) )
} }
} }
private val TAB_LABEL_CHROME = 56.dp