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 ## [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 ### 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
@@ -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 - 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 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. 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 - **„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 = 20 versionCode = 21
versionName = "1.17" versionName = "1.18"
// 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.
@@ -25,33 +25,34 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
* Rollbalken zum Ziehen fuer eine [LazyListState] — das Gegenstueck zum verbreiterten Balken im Web. * 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 * 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 * 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 * 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 * 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.
* *
* 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 * 1. **Die Greifflaeche muss breiter sein als der sichtbare Balken.** Sichtbar 6 dp, anfassbar
* [griffBreite] (Vorgabe 28 dp). 6 dp am Bildschirmrand trifft mit dem Finger niemand — * [griffBreite]. 6 dp am Bildschirmrand trifft mit dem Finger niemand.
* Androids eigene Vorgabe fuer Bedienelemente liegt bei 48 dp. * 2. **Der erste Fingerkontakt muss verbraucht werden**, sonst startet die LazyColumn ihre eigene
* 2. **Der erste Fingerkontakt wird verbraucht.** Sonst startet die LazyColumn ihre eigene * Scrollgeste, sobald die Wischschwelle faellt, und der Balken sieht die Bewegung nie.
* Scrollgeste, sobald der Finger die Schwelle ueberschreitet, und der Balken bekommt die * 3. **`pointerInput` darf NICHT auf die Geometrie geschluesselt sein.** Genau das war der Grund,
* Bewegung nie zu sehen. `detectVerticalDragGestures` wartet genau diese Schwelle ab und * warum das Ziehen unbrauchbar blieb: die Daumenposition steht in jedem Bild anders da, ein
* verliert das Rennen deshalb immer. * 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 * Gescrollt wird nicht im Zeigerereignis, sondern in einem eigenen Effekt, der [zielPos] verfolgt.
* bleibt das Wischen am rechten Rand normales Scrollen — sonst kaeme dort plotzlich die * Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte konkurrierende Scroll-Aufrufe.
* umgekehrte Richtung heraus, weil Balken ziehen und Inhalt wischen entgegengesetzt laufen.
*/ */
@Composable @Composable
fun Rollbalken( fun Rollbalken(
@@ -62,13 +63,12 @@ fun Rollbalken(
minHoehe: Dp = 44.dp, minHoehe: Dp = 44.dp,
farbe: Color = Color.White, farbe: Color = Color.White,
) { ) {
val dichte = LocalDensity.current val dichte = LocalDensity.current
val scope = rememberCoroutineScope() var spurPx by remember { mutableFloatStateOf(0f) }
var spurPx by remember { mutableFloatStateOf(0f) } var ziehen by remember { mutableStateOf(false) }
var ziehen by remember { mutableStateOf(false) } // Beim Ziehen fuehrt diese Kommazahl. Wuerde jeder Zeigerschritt aus firstVisibleItemIndex neu
// Beim Ziehen laeuft die Position als Kommazahl mit. Wuerde jeder Zeiger-Schritt aus // rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal verloren — der Balken
// firstVisibleItemIndex neu rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal // bliebe bei langsamem Ziehen stehen.
// verloren — der Balken bliebe bei langsamem Ziehen stehen.
var zielPos by remember { mutableFloatStateOf(0f) } var zielPos by remember { mutableFloatStateOf(0f) }
val info = state.layoutInfo val info = state.layoutInfo
@@ -90,24 +90,32 @@ fun Rollbalken(
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 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( Box(
modifier modifier
.width(griffBreite) .width(griffBreite)
.fillMaxHeight() .fillMaxHeight()
.onSizeChanged { spurPx = it.height.toFloat() } .onSizeChanged { spurPx = it.height.toFloat() }
.pointerInput(gesamt, zuSehen, frei, daumenY, daumenPx) { .pointerInput(Unit) {
awaitEachGesture { awaitEachGesture {
// requireUnconsumed = false: der Druck darf schon durch andere gesehen worden
// sein, wir wollen ihn trotzdem bewerten.
val start = awaitFirstDown(requireUnconsumed = false) val start = awaitFirstDown(requireUnconsumed = false)
val aufDemDaumen = start.position.y >= daumenY - spielPx && val g = geo.value
start.position.y <= daumenY + daumenPx + spielPx val aufDemDaumen = start.position.y >= g.daumenY - g.spiel &&
if (!aufDemDaumen || frei <= 0f) return@awaitEachGesture // Liste scrollt normal start.position.y <= g.daumenY + g.daumenPx + g.spiel
if (!aufDemDaumen || g.frei <= 0f) return@awaitEachGesture
start.consume() start.consume()
ziehen = true
zielPos = state.firstVisibleItemIndex.toFloat() zielPos = state.firstVisibleItemIndex.toFloat()
ziehen = true
try { try {
while (true) { while (true) {
val ereignis = awaitPointerEvent() val ereignis = awaitPointerEvent()
@@ -116,9 +124,11 @@ fun Rollbalken(
val delta = punkt.positionChange().y val delta = punkt.positionChange().y
punkt.consume() punkt.consume()
if (delta != 0f) { if (delta != 0f) {
zielPos = (zielPos + delta * maxIndex / frei) val jetzt = geo.value
.coerceIn(0f, maxIndex.toFloat()) if (jetzt.frei > 0f) {
scope.launch { state.scrollToItem(zielPos.roundToInt()) } zielPos = (zielPos + delta * jetzt.maxIndex / jetzt.frei)
.coerceIn(0f, jetzt.maxIndex)
}
} }
} }
} finally { } 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,
)