home: reveal new front items and brick-pack genre chips

Scroll a recently played row back to the front when a new first item
appears while it is already at/near the start; the lazy layout otherwise
anchors the old first item and hides the new one. Balance the three
genre rows by estimated width and tighten their gap.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent ea74fce758
commit 31ed5064a5
4 changed files with 195 additions and 6 deletions
@@ -15,8 +15,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -42,7 +44,11 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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
@@ -505,8 +511,51 @@ private val ARTIST_TILE_SIZE = 104.dp
private val TILE_SPACING = 8.dp
private val ARTIST_SPACING = 16.dp
private val SECTION_CONTENT_TOP = 4.dp
private val GENRE_ROW_SPACING = 4.dp
private val GENRE_ROW_SPACING = 2.dp
private const val GENRE_ROWS = 3
private const val GENRE_CHIP_WIDTH = 6
internal fun brickRows(
genres: List<String>,
rows: Int,
): List<List<String>> {
if (rows < 2) return listOf(genres)
val balanced = List(rows) { mutableListOf<String>() }
val widths = LongArray(rows)
genres.forEach { genre ->
val shortest = widths.indices.minBy { widths[it] }
balanced[shortest] += genre
widths[shortest] += genre.length + GENRE_CHIP_WIDTH
}
return balanced
}
private const val REVEAL_NEAR_FRONT = 1
internal fun shouldRevealNewFront(
previousKey: Any?,
firstKey: Any?,
firstVisibleItemIndex: Int,
): Boolean = firstKey != null && previousKey != null && previousKey != firstKey && firstVisibleItemIndex <= REVEAL_NEAR_FRONT
/**
* Scrolls a row back to the front when a new first item appears while it is already at/near the
* start. The lazy layout anchors to the previously visible key, so an item that moves to the front
* after a play lands left of the viewport and is not revealed.
*/
@Composable
internal fun <T> RevealNewFrontItem(
items: List<T>,
state: LazyListState,
key: (T) -> Any?,
) {
val firstKey = items.firstOrNull()?.let(key)
var previous by remember { mutableStateOf<Any?>(null) }
LaunchedEffect(firstKey) {
if (shouldRevealNewFront(previous, firstKey, state.firstVisibleItemIndex)) state.animateScrollToItem(0)
if (firstKey != null) previous = firstKey
}
}
private fun LazyListScope.albumRow(
section: HomeSection,
@@ -521,7 +570,10 @@ private fun LazyListScope.albumRow(
HomeSectionHeader(section.title, section.icon) { onMore(section) }
}
item(key = "${section.name}-row") {
val rowState = rememberLazyListState()
RevealNewFrontItem(albums, rowState) { it.id }
LazyRow(
state = rowState,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(TILE_SPACING),
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
@@ -552,7 +604,10 @@ private fun LazyListScope.artistRow(
HomeSectionHeader(section.title, section.icon) { onMore(section) }
}
item(key = "${section.name}-row") {
val rowState = rememberLazyListState()
RevealNewFrontItem(artists, rowState) { it.id }
LazyRow(
state = rowState,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(ARTIST_SPACING),
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
@@ -588,13 +643,13 @@ private fun LazyListScope.genreBlock(
.horizontalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, top = SECTION_CONTENT_TOP),
) {
val rows = remember(genres) { brickRows(genres, GENRE_ROWS) }
Column(verticalArrangement = Arrangement.spacedBy(GENRE_ROW_SPACING)) {
repeat(GENRE_ROWS) { row ->
rows.forEach { rowGenres ->
if (rowGenres.isEmpty()) return@forEach
Row(horizontalArrangement = Arrangement.spacedBy(TILE_SPACING)) {
genres.forEachIndexed { index, genre ->
if (index % GENRE_ROWS == row) {
AssistChip(onClick = { onGenreClick(genre) }, label = { Text(genre) })
}
rowGenres.forEach { genre ->
AssistChip(onClick = { onGenreClick(genre) }, label = { Text(genre) })
}
}
}
@@ -0,0 +1,41 @@
package com.subtracks.ui.home
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class GenreRowsTest {
private fun weight(genre: String) = genre.length + 6
@Test
fun everyGenreIsPlacedExactlyOnce() {
val genres = listOf("Electronic", "Rock", "Pop", "Jazz", "Classical", "Ambient")
val rows = brickRows(genres, 3)
assertEquals(3, rows.size)
assertEquals(genres.sorted(), rows.flatten().sorted())
}
@Test
fun rowsBalanceByWidthNotCount() {
val genres =
listOf(
"VeryLongGenreName1",
"A",
"B",
"VeryLongGenreName2",
"C",
"D",
"VeryLongGenreName3",
"E",
"F",
)
val rows = brickRows(genres, 3)
val widths = rows.map { row -> row.sumOf(::weight) }
val largestChip = genres.maxOf(::weight)
assertTrue(widths.max() - widths.min() <= largestChip)
}
}
@@ -0,0 +1,56 @@
package com.subtracks.ui.home
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
class HomeRevealScrollTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun aNewFrontItemAtTheStartScrollsBack() {
val labels = mutableStateOf(listOf("a", "b", "c", "d", "e"))
lateinit var state: LazyListState
composeRule.setContent {
SubtracksTheme {
state = rememberLazyListState()
RevealNewFrontItem(labels.value, state) { it }
LazyRow(state = state, modifier = Modifier.fillMaxSize()) {
items(labels.value, key = { it }) { label ->
Text(label, modifier = Modifier.width(200.dp).height(80.dp))
}
}
}
}
composeRule.waitForIdle()
assertEquals(0, state.firstVisibleItemIndex)
composeRule.runOnIdle { labels.value = listOf("z") + labels.value }
composeRule.waitForIdle()
assertEquals(0, state.firstVisibleItemIndex)
}
}
@@ -0,0 +1,37 @@
package com.subtracks.ui.home
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class HomeRevealTest {
@Test
fun noPreviousFrontDoesNotReveal() {
assertFalse(shouldRevealNewFront(previousKey = null, firstKey = "a", firstVisibleItemIndex = 0))
}
@Test
fun noFrontItemDoesNotReveal() {
assertFalse(shouldRevealNewFront(previousKey = "a", firstKey = null, firstVisibleItemIndex = 0))
}
@Test
fun anUnchangedFrontDoesNotReveal() {
assertFalse(shouldRevealNewFront(previousKey = "a", firstKey = "a", firstVisibleItemIndex = 0))
}
@Test
fun aChangedFrontRevealsAtTheStart() {
assertTrue(shouldRevealNewFront(previousKey = "a", firstKey = "b", firstVisibleItemIndex = 0))
}
@Test
fun aChangedFrontRevealsOneRowIn() {
assertTrue(shouldRevealNewFront(previousKey = "a", firstKey = "b", firstVisibleItemIndex = 1))
}
@Test
fun aChangedFrontDoesNotRevealWhenScrolledAway() {
assertFalse(shouldRevealNewFront(previousKey = "a", firstKey = "b", firstVisibleItemIndex = 2))
}
}