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
+64 -26

No files matched your search

+10 -1
View File
@@ -79,7 +79,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
## [3.1] — 2026-09-22 ## [3.1] — 2026-09-22
**Android:** 1.15 (versionCode 18) → 1.16 (versionCode 19). 1.16 bringt den Rollbalken. **Android:** 1.15 (versionCode 18) → 1.17 (versionCode 20). 1.16 brachte den Rollbalken, 1.17 machte ihn greifbar.
### Changed ### Changed
- **Der Rollbalken ist greifbar (Web)** — er war 4 px breit. Sichtbar reichte das, mit der Maus war - **Der Rollbalken ist greifbar (Web)** — er war 4 px breit. Sichtbar reichte das, mit der Maus war
@@ -105,6 +105,15 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
- Beim Ziehen läuft die Position als Kommazahl mit. Würde jeder Zeigerschritt aus - Beim Ziehen läuft die Position als Kommazahl mit. Würde jeder Zeigerschritt aus
`firstVisibleItemIndex` neu rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal `firstVisibleItemIndex` neu rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal
verloren und der Balken bliebe bei langsamem Ziehen stehen. verloren und der Balken bliebe bei langsamem Ziehen stehen.
- **Nachtrag 1.17: greifen ging zuerst gar nicht.** Zwei Fehler auf einmal. Die Greiffläche war
so schmal wie der sichtbare Balken (8 dp) — am Bildschirmrand trifft das mit dem Finger niemand,
Androids eigene Vorgabe für Bedienelemente liegt bei 48 dp. Und `detectVerticalDragGestures`
wartet die Wischschwelle ab, bevor es die Geste beansprucht; die `LazyColumn` startet ihr
eigenes Scrollen in genau diesem Moment und gewinnt jedes Mal. Jetzt sind sichtbar 6 dp und
anfassbar 28 dp, und der erste Fingerkontakt wird sofort verbraucht, bevor die Liste ihn sieht.
- Verbraucht wird nur, wenn der Finger **auf dem Daumen** aufsetzt (16 dp Spiel). Daneben bleibt
das Wischen am rechten Rand normales Scrollen — sonst käme dort plötzlich die umgekehrte
Richtung heraus, weil Balken ziehen und Inhalt wischen entgegengesetzt laufen.
- **„Neu von deinen Künstlern" sitzt jetzt in den Favoriten (Web)** — es stand links unter - **„Neu von deinen Künstlern" sitzt jetzt in den Favoriten (Web)** — es stand links unter
*Entdecken*, in Android dagegen als Knopf „✨ Neu" in den Favoriten. Zwei Plattformen, zwei Orte *Entdecken*, in Android dagegen als Knopf „✨ Neu" in den Favoriten. Zwei Plattformen, zwei Orte
für dieselbe Sache. Das Web folgt jetzt Android: der Eintrag in der Seitenleiste ist weg, in der für dieselbe Sache. Das Web folgt jetzt Android: der Eintrag in der Seitenleiste ist weg, in der
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.novasonic.app" applicationId = "com.novasonic.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 19 versionCode = 20
versionName = "1.16" versionName = "1.17"
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in // Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der // gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
// Platzhalter stehen und der App-Link greift schlicht nicht. // 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.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height 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.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp 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 * auch nicht ausrechnen. Ueber den Anteil sichtbarer an allen Eintraegen stimmt der Balken bei
* gleich hohen Zeilen — und genau die haben Song- und Albumlisten. * gleich hohen Zeilen — und genau die haben Song- und Albumlisten.
* *
* Sichtbar bleibt er dauerhaft, nur gedaempft. Ein Balken, der erst beim Scrollen erscheint, laesst * Zwei Dinge sind beim Greifen entscheidend und waren in der ersten Fassung falsch:
* sich nicht greifen, und Greifen ist hier der ganze Zweck. *
* 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 @Composable
fun Rollbalken( fun Rollbalken(
state: LazyListState, state: LazyListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
breite: Dp = 8.dp, breite: Dp = 6.dp,
griffBreite: Dp = 28.dp,
minHoehe: Dp = 44.dp, minHoehe: Dp = 44.dp,
farbe: Color = Color.White, farbe: Color = Color.White,
) { ) {
@@ -59,52 +74,66 @@ fun Rollbalken(
val info = state.layoutInfo val info = state.layoutInfo
val gesamt = info.totalItemsCount val gesamt = info.totalItemsCount
val zuSehen = info.visibleItemsInfo.size 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( val alpha by animateFloatAsState(
targetValue = if (state.isScrollInProgress || ziehen) 0.85f else 0.28f, targetValue = if (aktiv) 0.9f else 0.3f,
animationSpec = tween(if (state.isScrollInProgress || ziehen) 120 else 450), animationSpec = tween(if (aktiv) 120 else 450),
label = "rollbalken-alpha", label = "rollbalken-alpha",
) )
if (!scrollbar) return
val maxIndex = (gesamt - zuSehen).coerceAtLeast(1) val maxIndex = (gesamt - zuSehen).coerceAtLeast(1)
val minHoehePx = with(dichte) { minHoehe.toPx() } val minHoehePx = with(dichte) { minHoehe.toPx() }
val daumenPx = (spurPx * zuSehen / gesamt).coerceIn(minHoehePx.coerceAtMost(spurPx), spurPx) val daumenPx = (spurPx * zuSehen / gesamt).coerceIn(minHoehePx.coerceAtMost(spurPx), spurPx)
val frei = (spurPx - daumenPx).coerceAtLeast(0f) val frei = (spurPx - daumenPx).coerceAtLeast(0f)
// Waehrend des Ziehens fuehrt die Kommaposition, sonst der tatsaechliche Listenstand.
val anteil = if (ziehen) zielPos / maxIndex val anteil = if (ziehen) zielPos / maxIndex
else state.firstVisibleItemIndex.toFloat() / 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( Box(
modifier modifier
.width(breite) .width(griffBreite)
.fillMaxHeight() .fillMaxHeight()
.onSizeChanged { spurPx = it.height.toFloat() } .onSizeChanged { spurPx = it.height.toFloat() }
.pointerInput(gesamt, zuSehen, frei) { .pointerInput(gesamt, zuSehen, frei, daumenY, daumenPx) {
detectVerticalDragGestures( awaitEachGesture {
onDragStart = { // 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 ziehen = true
zielPos = state.firstVisibleItemIndex.toFloat() zielPos = state.firstVisibleItemIndex.toFloat()
}, try {
onDragEnd = { ziehen = false }, while (true) {
onDragCancel = { ziehen = false }, val ereignis = awaitPointerEvent()
) { change, delta -> val punkt = ereignis.changes.firstOrNull { it.id == start.id } ?: break
change.consume() if (!punkt.pressed) break
if (frei <= 0f) return@detectVerticalDragGestures val delta = punkt.positionChange().y
zielPos = (zielPos + delta * maxIndex / frei).coerceIn(0f, maxIndex.toFloat()) punkt.consume()
if (delta != 0f) {
zielPos = (zielPos + delta * maxIndex / frei)
.coerceIn(0f, maxIndex.toFloat())
scope.launch { state.scrollToItem(zielPos.roundToInt()) } scope.launch { state.scrollToItem(zielPos.roundToInt()) }
} }
} }
} finally {
ziehen = false
}
}
}
) { ) {
Box( Box(
Modifier Modifier
.align(Alignment.TopEnd)
.offset { IntOffset(0, daumenY.roundToInt()) } .offset { IntOffset(0, daumenY.roundToInt()) }
.padding(end = 2.dp)
.width(breite) .width(breite)
.height(with(dichte) { daumenPx.toDp() }) .height(with(dichte) { daumenPx.toDp() })
.padding(horizontal = 2.dp)
.clip(RoundedCornerShape(50)) .clip(RoundedCornerShape(50))
.background(farbe.copy(alpha = alpha)) .background(farbe.copy(alpha = alpha))
) )