size up the artist shuffle and keep the title clear of it

- make the shuffle a filled pill matching the album/playlist controls
- wrap the artist name with a hanging indent: the first line may run the
  full width over the button, while the last line stops short of it
This commit is contained in:
austinried
2026-10-04 00:30:02 +00:00
parent 581c5e20a3
commit af9b4e304c
2 changed files with 145 additions and 26 deletions
@@ -19,10 +19,10 @@ 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.navigationBars import androidx.compose.foundation.layout.navigationBars
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
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -36,6 +36,7 @@ import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
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
@@ -59,10 +60,13 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
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.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -92,6 +96,10 @@ import org.koin.core.parameter.parametersOf
private val ART_HEIGHT = 420.dp private val ART_HEIGHT = 420.dp
private val TITLE_INSET = 16.dp private val TITLE_INSET = 16.dp
private val ROW_GAP = 16.dp private val ROW_GAP = 16.dp
private val SHUFFLE_WIDTH = 68.dp
private val SHUFFLE_HEIGHT = 48.dp
private val SHUFFLE_ICON = 30.dp
private val SHUFFLE_RESERVE = 80.dp
internal fun estimateScrollPx( internal fun estimateScrollPx(
index: Int, index: Int,
@@ -272,19 +280,28 @@ fun ArtistDetailScreen(
) )
} }
Box(Modifier.align(Alignment.BottomStart).fillMaxWidth()) { Box(Modifier.align(Alignment.BottomStart).fillMaxWidth()) {
Text( HangingTitle(
text = artist?.name.orEmpty(), text = artist?.name.orEmpty(),
style = imageNameStyle, style = imageNameStyle,
color = Color.White, color = Color.White,
maxLines = 2, endReserve = SHUFFLE_RESERVE,
overflow = TextOverflow.Ellipsis, modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(TITLE_INSET),
modifier = Modifier.fillMaxWidth().padding(TITLE_INSET),
) )
IconButton( FilledIconButton(
onClick = onShuffle, onClick = onShuffle,
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 8.dp, bottom = 8.dp), shape = RoundedCornerShape(24.dp),
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(end = TITLE_INSET, bottom = TITLE_INSET)
.width(SHUFFLE_WIDTH)
.height(SHUFFLE_HEIGHT),
) { ) {
ShadowedIcon(Icons.Rounded.Shuffle, "Shuffle artist", size = 28.dp) Icon(
imageVector = Icons.Rounded.Shuffle,
contentDescription = "Shuffle artist",
modifier = Modifier.size(SHUFFLE_ICON),
)
} }
} }
} }
@@ -408,23 +425,62 @@ fun ArtistDetailScreen(
} }
@Composable @Composable
private fun ShadowedIcon( private fun HangingTitle(
imageVector: ImageVector, text: String,
contentDescription: String, style: TextStyle,
size: Dp = 24.dp, color: Color,
endReserve: Dp,
modifier: Modifier = Modifier,
) { ) {
Box(Modifier.size(size)) { val measurer = rememberTextMeasurer()
Icon( val density = LocalDensity.current
imageVector = imageVector, BoxWithConstraints(modifier) {
contentDescription = null, val fullWidth = with(density) { maxWidth.roundToPx() }
tint = Color.Black.copy(alpha = 0.5f), val reserve = with(density) { endReserve.roundToPx() }
modifier = Modifier.matchParentSize().offset(x = 0.5.dp, y = 1.5.dp), val lines = remember(text, style, fullWidth, reserve) { splitTitle(measurer, text, style, fullWidth, reserve) }
) Column {
Icon( lines.forEachIndexed { index, line ->
imageVector = imageVector, Text(
contentDescription = contentDescription, text = line,
tint = Color.White, style = style,
modifier = Modifier.matchParentSize(), color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.then(if (index == lines.lastIndex) Modifier.padding(end = endReserve) else Modifier),
) )
} }
} }
}
}
internal fun splitTitle(
measurer: TextMeasurer,
text: String,
style: TextStyle,
fullWidth: Int,
reserve: Int,
): List<String> {
if (text.isBlank()) return listOf(text)
if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) {
return listOf(text)
}
val words = text.split(' ').filter { it.isNotEmpty() }
if (words.size <= 1) return listOf(text)
var count = 0
var acc = ""
for (i in words.indices) {
val candidate = if (acc.isEmpty()) words[i] else "$acc ${words[i]}"
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
acc = candidate
count = i + 1
} else {
break
}
}
if (count >= words.size) count = words.size - 1
if (count < 1) count = 1
return listOf(words.take(count).joinToString(" "), words.drop(count).joinToString(" "))
}
@@ -0,0 +1,63 @@
package com.subtracks.ui.library
import androidx.activity.ComponentActivity
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HangingTitleTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private var measurer: TextMeasurer? = null
private var style: TextStyle = TextStyle.Default
private var fullWidth = 0
private var reserve = 0
private fun setUpMeasurements() {
composeRule.setContent {
SubtracksTheme {
measurer = rememberTextMeasurer()
style = MaterialTheme.typography.headlineLarge
val density = LocalDensity.current
fullWidth = with(density) { 360.dp.roundToPx() }
reserve = with(density) { 80.dp.roundToPx() }
}
}
composeRule.waitForIdle()
}
@Test
fun aShortNameStaysOnOneLine() {
setUpMeasurements()
assertEquals(listOf("Boards"), splitTitle(measurer!!, "Boards", style, fullWidth, reserve))
}
@Test
fun theLastLineStopsBeforeTheReservedWidth() {
setUpMeasurements()
val lines = splitTitle(measurer!!, "Supercalifragilisticexpialidocious x", style, fullWidth, reserve)
assertEquals(2, lines.size)
assertEquals("x", lines.last())
val lastWidth = measurer!!.measure(AnnotatedString(lines.last()), style, maxLines = 1).size.width
assertTrue("the last line must stop before the reserved width", lastWidth <= fullWidth - reserve)
}
}