From 77a3bb33f021bd66c4e7b959c4accee09b787948 Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 22 Sep 2026 20:24:37 +0200 Subject: [PATCH] fix(android): Rollbalken-Geste brach ab, weil pointerInput auf die Geometrie geschluesselt war Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 ++- android/app/build.gradle.kts | 4 +- .../novasonic/app/ui/component/Rollbalken.kt | 81 ++++++++++++------- 3 files changed, 62 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 490cc76..2fe8444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 03caed7..6f86b72 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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. diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/Rollbalken.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/Rollbalken.kt index f2579c7..61be3f3 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/component/Rollbalken.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/Rollbalken.kt @@ -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, +)