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