From 31ed5064a5b77515512d6b6d7e7b4f7902875023 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 3 Oct 2026 06:35:56 +0000 Subject: [PATCH] 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. --- .../com/subtracks/ui/home/HomeScreen.kt | 67 +++++++++++++++++-- .../com/subtracks/ui/home/GenreRowsTest.kt | 41 ++++++++++++ .../subtracks/ui/home/HomeRevealScrollTest.kt | 56 ++++++++++++++++ .../com/subtracks/ui/home/HomeRevealTest.kt | 37 ++++++++++ 4 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 app/src/test/kotlin/com/subtracks/ui/home/GenreRowsTest.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/home/HomeRevealScrollTest.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/home/HomeRevealTest.kt diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt index f999dd1e..1f8400de 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt @@ -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, + rows: Int, +): List> { + if (rows < 2) return listOf(genres) + val balanced = List(rows) { mutableListOf() } + 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 RevealNewFrontItem( + items: List, + state: LazyListState, + key: (T) -> Any?, +) { + val firstKey = items.firstOrNull()?.let(key) + var previous by remember { mutableStateOf(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) }) } } } diff --git a/app/src/test/kotlin/com/subtracks/ui/home/GenreRowsTest.kt b/app/src/test/kotlin/com/subtracks/ui/home/GenreRowsTest.kt new file mode 100644 index 00000000..19b9009b --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/home/GenreRowsTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/home/HomeRevealScrollTest.kt b/app/src/test/kotlin/com/subtracks/ui/home/HomeRevealScrollTest.kt new file mode 100644 index 00000000..cc070007 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/home/HomeRevealScrollTest.kt @@ -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() + + @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) + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/home/HomeRevealTest.kt b/app/src/test/kotlin/com/subtracks/ui/home/HomeRevealTest.kt new file mode 100644 index 00000000..7d1a99d9 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/home/HomeRevealTest.kt @@ -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)) + } +}