From f083fad98657841df20379174ba9230cc60b744b Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 22 Sep 2026 20:20:08 +0200 Subject: [PATCH] =?UTF-8?q?fix(android):=20Rollbalken=20liess=20sich=20nic?= =?UTF-8?q?ht=20greifen=20=E2=80=94=20Greifflaeche=20und=20Gestenvorrang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 67 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3d4a80..6609156 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.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 - **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 `firstVisibleItemIndex` neu rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal 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 *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 ea6dd32..03caed7 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 = 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. 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 bab1699..f2579c7 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 @@ -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)) )