feat(android): artist fast-scroll index bar with letter bubble

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-18 20:06:44 +02:00
1 parent dd484df636
commit 9ffd071c58
1 file changed
+97 -2
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.border
@@ -20,9 +21,12 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -37,6 +41,7 @@ import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.AlbumSort
import com.novasonic.app.viewmodel.LibraryTab
import com.novasonic.app.viewmodel.LibraryViewModel
import kotlinx.coroutines.launch
@Composable
fun LibraryScreen(
@@ -145,8 +150,26 @@ fun LibraryScreen(
}
}
LibraryTab.ARTISTS -> LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
LibraryTab.ARTISTS -> {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
// Map letter → flat LazyColumn index of its header item
val letterToIndex = remember(state.artistIndexes) {
buildMap<String, Int> {
var flat = 0
state.artistIndexes.forEach { idx ->
put(idx.name, flat)
flat += 1 + idx.artist.size
}
}
}
var activeLetter by remember { mutableStateOf<String?>(null) }
Box(Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
modifier = Modifier.fillMaxSize(),
) {
state.artistIndexes.forEach { idx ->
@@ -186,6 +209,78 @@ fun LibraryScreen(
}
}
if (letters.isNotEmpty()) {
// Bubble showing active letter while dragging
activeLetter?.let { letter ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.align(Alignment.CenterEnd)
.offset(x = (-44).dp)
.size(52.dp)
.clip(CircleShape)
.background(NsAccent),
) {
Text(
text = letter,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = NsBackground,
)
}
}
// Vertical letter strip
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.width(28.dp)
.pointerInput(letters) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val y = event.changes.firstOrNull()?.position?.y ?: continue
val idx = ((y / size.height) * letters.size)
.toInt().coerceIn(0, letters.lastIndex)
val letter = letters[idx]
when (event.type) {
PointerEventType.Press,
PointerEventType.Move -> {
event.changes.forEach { it.consume() }
if (letter != activeLetter) {
activeLetter = letter
letterToIndex[letter]?.let { pos ->
scope.launch { listState.scrollToItem(pos) }
}
}
}
else -> activeLetter = null
}
}
}
},
) {
Column(
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize(),
) {
letters.forEach { letter ->
Text(
text = letter,
fontSize = 10.sp,
fontWeight = if (letter == activeLetter) FontWeight.Bold else FontWeight.Normal,
color = if (letter == activeLetter) NsAccent else NsOnSurface3,
textAlign = TextAlign.Center,
)
}
}
}
}
}
}
LibraryTab.PLAYLISTS -> LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 80.dp),