fix(android): Rollbalken-Geste brach ab, weil pointerInput auf die Geometrie geschluesselt war

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-22 20:24:37 +02:00
1 parent d420123a44
commit 77a3bb33f0
3 files changed
+62 -34

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
**Android:** 1.15 (versionCode 18) → 1.17 (versionCode 20). 1.16 brachte den Rollbalken, 1.17 machte ihn greifbar.
**Android:** 1.15 (versionCode 18) → 1.18 (versionCode 21). 1.16 brachte den Rollbalken, 1.17 und 1.18 machten ihn tatsächlich bedienbar.
### Changed
- **Der Rollbalken ist greifbar (Web)** — er war 4 px breit. Sichtbar reichte das, mit der Maus war
@@ -114,6 +114,15 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
- 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.
- **Nachtrag 1.18: das Ziehen brach sofort wieder ab.** `pointerInput` war auf die Geometrie
geschlüsselt — unter anderem auf die Daumenposition, und die steht in jedem Bild anders da. Ein
geänderter Schlüssel setzt die Gestenerkennung neu auf: der Finger lag noch auf dem Schirm, die
Erkennung fing wieder bei null an. Je länger die Liste, desto öfter, deshalb war es bei 2000
Titeln unbrauchbar. Der Schlüssel ist jetzt `Unit`; die wechselnden Werte liest die Geste über
`rememberUpdatedState` zur Laufzeit nach.
- Gescrollt wird außerdem nicht mehr im Zeigerereignis, sondern in **einem** Effekt, der die
Zielposition verfolgt. Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte
konkurrierende Scroll-Aufrufe, die sich gegenseitig überholten.
- **„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
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"
minSdk = 26
targetSdk = 35
versionCode = 20
versionName = "1.17"
versionCode = 21
versionName = "1.18"
// 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.
@@ -25,33 +25,34 @@ 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.
* Playlist mit 2000 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.
*
* Zwei Dinge sind beim Greifen entscheidend und waren in der ersten Fassung falsch:
* Drei Fallen steckten hier drin, alle beim Ziehen:
*
* 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.
* 1. **Die Greifflaeche muss breiter sein als der sichtbare Balken.** Sichtbar 6 dp, anfassbar
* [griffBreite]. 6 dp am Bildschirmrand trifft mit dem Finger niemand.
* 2. **Der erste Fingerkontakt muss verbraucht werden**, sonst startet die LazyColumn ihre eigene
* Scrollgeste, sobald die Wischschwelle faellt, und der Balken sieht die Bewegung nie.
* 3. **`pointerInput` darf NICHT auf die Geometrie geschluesselt sein.** Genau das war der Grund,
* warum das Ziehen unbrauchbar blieb: die Daumenposition steht in jedem Bild anders da, ein
* Schluessel darauf setzt die Gestenerkennung also mitten in der Geste neu auf — der Finger
* liegt noch auf dem Schirm, die Erkennung faengt aber wieder bei null an. Der Schluessel ist
* deshalb `Unit`; die wechselnden Werte liest die Geste ueber [rememberUpdatedState] zur
* Laufzeit nach.
*
* 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.
* Gescrollt wird nicht im Zeigerereignis, sondern in einem eigenen Effekt, der [zielPos] verfolgt.
* Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte konkurrierende Scroll-Aufrufe.
*/
@Composable
fun Rollbalken(
@@ -62,13 +63,12 @@ fun Rollbalken(
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.
val dichte = LocalDensity.current
var spurPx by remember { mutableFloatStateOf(0f) }
var ziehen by remember { mutableStateOf(false) }
// Beim Ziehen fuehrt diese Kommazahl. Wuerde jeder Zeigerschritt 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
@@ -90,24 +90,32 @@ fun Rollbalken(
val anteil = if (ziehen) zielPos / maxIndex
else state.firstVisibleItemIndex.toFloat() / maxIndex
val daumenY = frei * anteil.coerceIn(0f, 1f)
val spielPx = with(dichte) { 16.dp.toPx() } // Spiel ober- und unterhalb des Daumens
val spielPx = with(dichte) { 24.dp.toPx() }
// Was die Geste zur Laufzeit braucht — nicht als pointerInput-Schluessel, siehe oben.
val geo = rememberUpdatedState(Geometrie(maxIndex.toFloat(), frei, daumenY, daumenPx, spielPx))
// Die Liste folgt zielPos in EINER Koroutine statt in einer je Fingerbewegung.
LaunchedEffect(ziehen) {
if (!ziehen) return@LaunchedEffect
snapshotFlow { zielPos.roundToInt() }.collect { state.scrollToItem(it) }
}
Box(
modifier
.width(griffBreite)
.fillMaxHeight()
.onSizeChanged { spurPx = it.height.toFloat() }
.pointerInput(gesamt, zuSehen, frei, daumenY, daumenPx) {
.pointerInput(Unit) {
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
val g = geo.value
val aufDemDaumen = start.position.y >= g.daumenY - g.spiel &&
start.position.y <= g.daumenY + g.daumenPx + g.spiel
if (!aufDemDaumen || g.frei <= 0f) return@awaitEachGesture
start.consume()
ziehen = true
zielPos = state.firstVisibleItemIndex.toFloat()
ziehen = true
try {
while (true) {
val ereignis = awaitPointerEvent()
@@ -116,9 +124,11 @@ fun Rollbalken(
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()) }
val jetzt = geo.value
if (jetzt.frei > 0f) {
zielPos = (zielPos + delta * jetzt.maxIndex / jetzt.frei)
.coerceIn(0f, jetzt.maxIndex)
}
}
}
} finally {
@@ -139,3 +149,12 @@ fun Rollbalken(
)
}
}
/** Nur ein Buendel der Werte, die die Geste zur Laufzeit nachliest. */
private data class Geometrie(
val maxIndex: Float,
val frei: Float,
val daumenY: Float,
val daumenPx: Float,
val spiel: Float,
)