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:
1 parent
d420123a44
commit
77a3bb33f0
3 files changed
+62
-34
No files matched your search
+10
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user