mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user