fix(android): Rollbalken liess sich nicht greifen — Greifflaeche und Gestenvorrang

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-22 20:20:08 +02:00
1 parent 363eb2b1aa
commit f083fad986
3 files changed
+67 -29

No files matched your search

+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.novasonic.app"
minSdk = 26
targetSdk = 35
versionCode = 19
versionName = "1.16"
versionCode = 20
versionName = "1.17"
// 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.
@@ -3,7 +3,8 @@ 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.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
@@ -13,10 +14,12 @@ 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.Alignment
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.input.pointer.positionChange
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
@@ -36,14 +39,26 @@ import kotlin.math.roundToInt
* 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.
* Zwei Dinge sind beim Greifen entscheidend und waren in der ersten Fassung falsch:
*
* 1. **Die Greifflaeche ist breiter als der sichtbare Balken.** Sichtbar sind 6 dp, anfassbar
* [griffBreite] (Vorgabe 28 dp). 6 dp am Bildschirmrand trifft mit dem Finger niemand —
* Androids eigene Vorgabe fuer Bedienelemente liegt bei 48 dp.
* 2. **Der erste Fingerkontakt wird verbraucht.** Sonst startet die LazyColumn ihre eigene
* Scrollgeste, sobald der Finger die Schwelle ueberschreitet, und der Balken bekommt die
* Bewegung nie zu sehen. `detectVerticalDragGestures` wartet genau diese Schwelle ab und
* verliert das Rennen deshalb immer.
*
* Verbraucht wird nur, wenn der Finger **auf dem Daumen** aufsetzt (mit etwas Spiel). Daneben
* bleibt das Wischen am rechten Rand normales Scrollen — sonst kaeme dort plotzlich die
* umgekehrte Richtung heraus, weil Balken ziehen und Inhalt wischen entgegengesetzt laufen.
*/
@Composable
fun Rollbalken(
state: LazyListState,
modifier: Modifier = Modifier,
breite: Dp = 8.dp,
breite: Dp = 6.dp,
griffBreite: Dp = 28.dp,
minHoehe: Dp = 44.dp,
farbe: Color = Color.White,
) {
@@ -59,52 +74,66 @@ fun Rollbalken(
val info = state.layoutInfo
val gesamt = info.totalItemsCount
val zuSehen = info.visibleItemsInfo.size
val scrollbar = gesamt > 0 && zuSehen > 0 && gesamt > zuSehen
if (gesamt == 0 || zuSehen == 0 || gesamt <= zuSehen) return
val aktiv = state.isScrollInProgress || ziehen
val alpha by animateFloatAsState(
targetValue = if (state.isScrollInProgress || ziehen) 0.85f else 0.28f,
animationSpec = tween(if (state.isScrollInProgress || ziehen) 120 else 450),
targetValue = if (aktiv) 0.9f else 0.3f,
animationSpec = tween(if (aktiv) 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))
val daumenY = frei * anteil.coerceIn(0f, 1f)
val spielPx = with(dichte) { 16.dp.toPx() } // Spiel ober- und unterhalb des Daumens
Box(
modifier
.width(breite)
.width(griffBreite)
.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()) }
.pointerInput(gesamt, zuSehen, frei, daumenY, daumenPx) {
awaitEachGesture {
// requireUnconsumed = false: der Druck darf schon durch andere gesehen worden
// sein, wir wollen ihn trotzdem bewerten.
val start = awaitFirstDown(requireUnconsumed = false)
val aufDemDaumen = start.position.y >= daumenY - spielPx &&
start.position.y <= daumenY + daumenPx + spielPx
if (!aufDemDaumen || frei <= 0f) return@awaitEachGesture // Liste scrollt normal
start.consume()
ziehen = true
zielPos = state.firstVisibleItemIndex.toFloat()
try {
while (true) {
val ereignis = awaitPointerEvent()
val punkt = ereignis.changes.firstOrNull { it.id == start.id } ?: break
if (!punkt.pressed) break
val delta = punkt.positionChange().y
punkt.consume()
if (delta != 0f) {
zielPos = (zielPos + delta * maxIndex / frei)
.coerceIn(0f, maxIndex.toFloat())
scope.launch { state.scrollToItem(zielPos.roundToInt()) }
}
}
} finally {
ziehen = false
}
}
}
) {
Box(
Modifier
.align(Alignment.TopEnd)
.offset { IntOffset(0, daumenY.roundToInt()) }
.padding(end = 2.dp)
.width(breite)
.height(with(dichte) { daumenPx.toDp() })
.padding(horizontal = 2.dp)
.clip(RoundedCornerShape(50))
.background(farbe.copy(alpha = alpha))
)