feat(android): Rollbalken zum Ziehen in den langen Listen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6652fb4d2b
commit
1aa218f3e3
7 files changed
+171
-7
No files matched your search
@@ -14,8 +14,8 @@ android {
|
||||
applicationId = "com.novasonic.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 18
|
||||
versionName = "1.15"
|
||||
versionCode = 19
|
||||
versionName = "1.16"
|
||||
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
|
||||
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
|
||||
// Platzhalter stehen und der App-Link greift schlicht nicht.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Rollbalken zum Ziehen fuer eine [LazyListState] — das Gegenstueck zum verbreiterten Balken im Web.
|
||||
*
|
||||
* Compose bringt keinen mit: eine LazyColumn zeigt gar nichts an, egal wie lang sie ist. Bei einer
|
||||
* Playlist mit 1900 Titeln bleibt so nur Wischen, bis der Daumen glueht.
|
||||
*
|
||||
* Gerechnet wird in ITEMS, nicht in Pixeln: die tatsaechliche Gesamthoehe einer LazyColumn ist
|
||||
* unbekannt, solange nicht alles gebaut wurde, und bei unterschiedlich hohen Zeilen laesst sie sich
|
||||
* auch nicht ausrechnen. Ueber den Anteil sichtbarer an allen Eintraegen stimmt der Balken bei
|
||||
* gleich hohen Zeilen — und genau die haben Song- und Albumlisten.
|
||||
*
|
||||
* Sichtbar bleibt er dauerhaft, nur gedaempft. Ein Balken, der erst beim Scrollen erscheint, laesst
|
||||
* sich nicht greifen, und Greifen ist hier der ganze Zweck.
|
||||
*/
|
||||
@Composable
|
||||
fun Rollbalken(
|
||||
state: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
breite: Dp = 8.dp,
|
||||
minHoehe: Dp = 44.dp,
|
||||
farbe: Color = Color.White,
|
||||
) {
|
||||
val dichte = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var spurPx by remember { mutableFloatStateOf(0f) }
|
||||
var ziehen by remember { mutableStateOf(false) }
|
||||
// Beim Ziehen laeuft die Position als Kommazahl mit. Wuerde jeder Zeiger-Schritt aus
|
||||
// firstVisibleItemIndex neu rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal
|
||||
// verloren — der Balken bliebe bei langsamem Ziehen stehen.
|
||||
var zielPos by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val info = state.layoutInfo
|
||||
val gesamt = info.totalItemsCount
|
||||
val zuSehen = info.visibleItemsInfo.size
|
||||
val scrollbar = gesamt > 0 && zuSehen > 0 && gesamt > zuSehen
|
||||
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (state.isScrollInProgress || ziehen) 0.85f else 0.28f,
|
||||
animationSpec = tween(if (state.isScrollInProgress || ziehen) 120 else 450),
|
||||
label = "rollbalken-alpha",
|
||||
)
|
||||
|
||||
if (!scrollbar) return
|
||||
|
||||
val maxIndex = (gesamt - zuSehen).coerceAtLeast(1)
|
||||
val minHoehePx = with(dichte) { minHoehe.toPx() }
|
||||
val daumenPx = (spurPx * zuSehen / gesamt).coerceIn(minHoehePx.coerceAtMost(spurPx), spurPx)
|
||||
val frei = (spurPx - daumenPx).coerceAtLeast(0f)
|
||||
// Waehrend des Ziehens fuehrt die Kommaposition, sonst der tatsaechliche Listenstand.
|
||||
val anteil = if (ziehen) zielPos / maxIndex
|
||||
else state.firstVisibleItemIndex.toFloat() / maxIndex
|
||||
val daumenY = (frei * anteil.coerceIn(0f, 1f))
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.width(breite)
|
||||
.fillMaxHeight()
|
||||
.onSizeChanged { spurPx = it.height.toFloat() }
|
||||
.pointerInput(gesamt, zuSehen, frei) {
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = {
|
||||
ziehen = true
|
||||
zielPos = state.firstVisibleItemIndex.toFloat()
|
||||
},
|
||||
onDragEnd = { ziehen = false },
|
||||
onDragCancel = { ziehen = false },
|
||||
) { change, delta ->
|
||||
change.consume()
|
||||
if (frei <= 0f) return@detectVerticalDragGestures
|
||||
zielPos = (zielPos + delta * maxIndex / frei).coerceIn(0f, maxIndex.toFloat())
|
||||
scope.launch { state.scrollToItem(zielPos.roundToInt()) }
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.offset { IntOffset(0, daumenY.roundToInt()) }
|
||||
.width(breite)
|
||||
.height(with(dichte) { daumenPx.toDp() })
|
||||
.padding(horizontal = 2.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(farbe.copy(alpha = alpha))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -37,6 +38,7 @@ import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.AlbumViewModel
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
|
||||
@Composable
|
||||
fun AlbumScreen(
|
||||
@@ -75,7 +77,9 @@ fun AlbumScreen(
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
@@ -240,4 +244,10 @@ fun AlbumScreen(
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
@@ -18,6 +19,7 @@ import com.novasonic.app.app
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
|
||||
@Composable
|
||||
fun GenreSongsScreen(
|
||||
@@ -77,7 +79,10 @@ fun GenreSongsScreen(
|
||||
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Songs gefunden", color = NsOnSurface2)
|
||||
}
|
||||
else -> LazyColumn {
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
@@ -91,6 +96,13 @@ fun GenreSongsScreen(
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -37,6 +38,7 @@ import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.OfflineDetailViewModel
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
|
||||
@Composable
|
||||
fun OfflineDetailScreen(
|
||||
@@ -78,7 +80,9 @@ fun OfflineDetailScreen(
|
||||
onPlay(liste, i)
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
@@ -205,4 +209,10 @@ fun OfflineDetailScreen(
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -39,6 +40,7 @@ import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.PlaylistViewModel
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
|
||||
@Composable
|
||||
fun PlaylistDetailScreen(
|
||||
@@ -91,7 +93,9 @@ fun PlaylistDetailScreen(
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
@@ -293,6 +297,12 @@ fun PlaylistDetailScreen(
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Rename dialog ─────────────────────────────────────────────────────────
|
||||
if (showRenameDialog) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -28,6 +29,7 @@ import com.novasonic.app.app
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
|
||||
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
|
||||
|
||||
@@ -142,7 +144,9 @@ fun StarredScreen(
|
||||
Text("Noch keine Favoriten", color = NsOnSurface2)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(state = listState) {
|
||||
if (gefolgteKuenstler.isNotEmpty()) {
|
||||
item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") }
|
||||
item {
|
||||
@@ -195,6 +199,12 @@ fun StarredScreen(
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user