ui: keep the artist gradient anchored to the hero image

Use symmetric page margins for the artist bio, let lines below the action buttons use the full width, and stop the expanding bio from shifting the hero gradient.
This commit is contained in:
austinried
2026-10-19 14:01:30 +00:00
parent 77190a2af1
commit 8348e048b5
2 changed files with 19 additions and 9 deletions
@@ -134,6 +134,7 @@ private val SHUFFLE_RESERVE = 86.dp
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2 private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
private val BIO_ACTION_RESERVE = 104.dp private val BIO_ACTION_RESERVE = 104.dp
private val BIO_ACTION_HEIGHT = 48.dp private val BIO_ACTION_HEIGHT = 48.dp
private val BIO_ACTION_OFFSET = 8.dp
private val BIO_TOP_GAP = 8.dp private val BIO_TOP_GAP = 8.dp
internal fun estimateScrollPx( internal fun estimateScrollPx(
@@ -363,7 +364,9 @@ fun ArtistDetailScreen(
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
val screenHeightPx = with(density) { maxHeight.toPx() } val screenHeightPx = with(density) { maxHeight.toPx() }
val barHeightPx = with(density) { barHeight.toPx() } val barHeightPx = with(density) { barHeight.toPx() }
val gradientScrollPx = scrollPx - imageHeightPx // The gradient is anchored to the hero image, not the whole header, whose bio can expand.
val heroHeightPx = with(density) { ART_HEIGHT.toPx() }
val gradientScrollPx = scrollPx - heroHeightPx
val barColor = heroBarColor(effectiveArtwork, gradientScrollPx, barHeightPx, screenHeightPx) val barColor = heroBarColor(effectiveArtwork, gradientScrollPx, barHeightPx, screenHeightPx)
HeroGradient( HeroGradient(
colors = effectiveArtwork, colors = effectiveArtwork,
@@ -441,7 +444,7 @@ fun ArtistDetailScreen(
} }
} }
Column( Column(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET), modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = TITLE_INSET),
) { ) {
Text( Text(
text = metaText, text = metaText,
@@ -579,12 +582,15 @@ private fun ArtistBio(
remember(style) { remember(style) {
measurer.measure(AnnotatedString("X"), style, maxLines = 1).size.height measurer.measure(AnnotatedString("X"), style, maxLines = 1).size.height
}.coerceAtLeast(1) }.coerceAtLeast(1)
val reserveLines = ceil(with(density) { BIO_ACTION_HEIGHT.toPx() } / lineHeightPx).toInt().coerceAtLeast(1) val reserveLines =
ceil(with(density) { (BIO_ACTION_HEIGHT - BIO_ACTION_OFFSET).toPx() } / lineHeightPx)
.toInt()
.coerceAtLeast(1)
val bioLines = val bioLines =
remember(text, fullWidth, reserve, reserveLines, collapsed, expanded, moreText, lessText) { remember(text, fullWidth, reserve, reserveLines, collapsed, expanded, moreText, lessText) {
when { when {
collapsed -> { collapsed -> {
splitBioCollapsed(measurer, text, style, fullWidth, reserve, moreText, maxLines = 3) splitBioCollapsed(measurer, text, style, fullWidth, reserve, reserveLines, moreText, maxLines = 3)
} }
expanded -> { expanded -> {
@@ -620,7 +626,7 @@ private fun ArtistBio(
) )
} }
} }
Row(Modifier.align(Alignment.TopEnd).offset(y = (-8).dp)) { Row(Modifier.align(Alignment.TopEnd).offset(y = -BIO_ACTION_OFFSET)) {
ArtistActions(artist, starred, onToggleStar, onMore) ArtistActions(artist, starred, onToggleStar, onMore)
} }
} }
@@ -742,6 +748,7 @@ internal fun splitBioCollapsed(
style: TextStyle, style: TextStyle,
fullWidth: Int, fullWidth: Int,
reserve: Int, reserve: Int,
reserveLines: Int,
moreText: String, moreText: String,
maxLines: Int = 2, maxLines: Int = 2,
): BioLines { ): BioLines {
@@ -752,13 +759,13 @@ internal fun splitBioCollapsed(
.split(' ') .split(' ')
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
if (words.isEmpty()) return BioLines(listOf(AnnotatedString("")), more = false) if (words.isEmpty()) return BioLines(listOf(AnnotatedString("")), more = false)
val available = (fullWidth - reserve).coerceAtLeast(1)
val lines = mutableListOf<AnnotatedString>() val lines = mutableListOf<AnnotatedString>()
var index = 0 var index = 0
while (index < words.size && lines.size < maxLines - 1) { while (index < words.size && lines.size < maxLines - 1) {
var current = "" var current = ""
while (index < words.size) { while (index < words.size) {
val candidate = if (current.isEmpty()) words[index] else "$current ${words[index]}" val candidate = if (current.isEmpty()) words[index] else "$current ${words[index]}"
val available = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) { if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
current = candidate current = candidate
index++ index++
@@ -776,8 +783,9 @@ internal fun splitBioCollapsed(
return BioLines(lines, more = false) return BioLines(lines, more = false)
} }
val remainingAvailable = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
val remaining = words.drop(index).joinToString(" ") val remaining = words.drop(index).joinToString(" ")
if (measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= available) { if (measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= remainingAvailable) {
lines += AnnotatedString(remaining) lines += AnnotatedString(remaining)
return BioLines(lines, more = false) return BioLines(lines, more = false)
} }
@@ -790,7 +798,7 @@ internal fun splitBioCollapsed(
val moreWidth = val moreWidth =
measurer.measure(more, style, maxLines = 1).size.width + measurer.measure(more, style, maxLines = 1).size.width +
measurer.measure(AnnotatedString(" "), style, maxLines = 1).size.width measurer.measure(AnnotatedString(" "), style, maxLines = 1).size.width
val lastAvailable = (available - moreWidth).coerceAtLeast(1) val lastAvailable = (bioLineWidth(lines.size, fullWidth, reserve, reserveLines) - moreWidth).coerceAtLeast(1)
var truncated = "" var truncated = ""
while (index < words.size) { while (index < words.size) {
val candidate = if (truncated.isEmpty()) words[index] else "$truncated ${words[index]}" val candidate = if (truncated.isEmpty()) words[index] else "$truncated ${words[index]}"
@@ -238,7 +238,7 @@ class ArtistDetailScreenTest {
var result: BioLines? = null var result: BioLines? = null
composeRule.setContent { composeRule.setContent {
val measurer = rememberTextMeasurer() val measurer = rememberTextMeasurer()
result = splitBioCollapsed(measurer, bio, TextStyle(fontSize = 14.sp), 400, 0, "More") result = splitBioCollapsed(measurer, bio, TextStyle(fontSize = 14.sp), 400, 0, 0, "More")
} }
composeRule.waitForIdle() composeRule.waitForIdle()
@@ -269,6 +269,7 @@ class ArtistDetailScreenTest {
TextStyle(fontSize = 14.sp), TextStyle(fontSize = 14.sp),
400, 400,
0, 0,
0,
"More", "More",
maxLines = 3, maxLines = 3,
) )
@@ -298,6 +299,7 @@ class ArtistDetailScreenTest {
TextStyle(fontSize = 14.sp), TextStyle(fontSize = 14.sp),
20, 20,
0, 0,
0,
"More", "More",
) )
} }