fix(android): Rollbalken liess sich nicht greifen — Greifflaeche und Gestenvorrang
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
363eb2b1aa
commit
f083fad986
3 files changed
+67
-29
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
|
## [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
|
### 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
|
||||||
@@ -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
|
- 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
|
`firstVisibleItemIndex` neu rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal
|
||||||
verloren und der Balken bliebe bei langsamem Ziehen stehen.
|
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
|
- **„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
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ android {
|
|||||||
applicationId = "com.novasonic.app"
|
applicationId = "com.novasonic.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 19
|
versionCode = 20
|
||||||
versionName = "1.16"
|
versionName = "1.17"
|
||||||
// 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.
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ package com.novasonic.app.ui.component
|
|||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
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.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.height
|
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.lazy.LazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.input.pointer.positionChange
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Dp
|
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
|
* 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.
|
||||||
*
|
*
|
||||||
* Sichtbar bleibt er dauerhaft, nur gedaempft. Ein Balken, der erst beim Scrollen erscheint, laesst
|
* Zwei Dinge sind beim Greifen entscheidend und waren in der ersten Fassung falsch:
|
||||||
* sich nicht greifen, und Greifen ist hier der ganze Zweck.
|
*
|
||||||
|
* 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
|
@Composable
|
||||||
fun Rollbalken(
|
fun Rollbalken(
|
||||||
state: LazyListState,
|
state: LazyListState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
breite: Dp = 8.dp,
|
breite: Dp = 6.dp,
|
||||||
|
griffBreite: Dp = 28.dp,
|
||||||
minHoehe: Dp = 44.dp,
|
minHoehe: Dp = 44.dp,
|
||||||
farbe: Color = Color.White,
|
farbe: Color = Color.White,
|
||||||
) {
|
) {
|
||||||
@@ -59,52 +74,66 @@ fun Rollbalken(
|
|||||||
val info = state.layoutInfo
|
val info = state.layoutInfo
|
||||||
val gesamt = info.totalItemsCount
|
val gesamt = info.totalItemsCount
|
||||||
val zuSehen = info.visibleItemsInfo.size
|
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(
|
val alpha by animateFloatAsState(
|
||||||
targetValue = if (state.isScrollInProgress || ziehen) 0.85f else 0.28f,
|
targetValue = if (aktiv) 0.9f else 0.3f,
|
||||||
animationSpec = tween(if (state.isScrollInProgress || ziehen) 120 else 450),
|
animationSpec = tween(if (aktiv) 120 else 450),
|
||||||
label = "rollbalken-alpha",
|
label = "rollbalken-alpha",
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!scrollbar) return
|
|
||||||
|
|
||||||
val maxIndex = (gesamt - zuSehen).coerceAtLeast(1)
|
val maxIndex = (gesamt - zuSehen).coerceAtLeast(1)
|
||||||
val minHoehePx = with(dichte) { minHoehe.toPx() }
|
val minHoehePx = with(dichte) { minHoehe.toPx() }
|
||||||
val daumenPx = (spurPx * zuSehen / gesamt).coerceIn(minHoehePx.coerceAtMost(spurPx), spurPx)
|
val daumenPx = (spurPx * zuSehen / gesamt).coerceIn(minHoehePx.coerceAtMost(spurPx), spurPx)
|
||||||
val frei = (spurPx - daumenPx).coerceAtLeast(0f)
|
val frei = (spurPx - daumenPx).coerceAtLeast(0f)
|
||||||
// Waehrend des Ziehens fuehrt die Kommaposition, sonst der tatsaechliche Listenstand.
|
|
||||||
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
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.width(breite)
|
.width(griffBreite)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.onSizeChanged { spurPx = it.height.toFloat() }
|
.onSizeChanged { spurPx = it.height.toFloat() }
|
||||||
.pointerInput(gesamt, zuSehen, frei) {
|
.pointerInput(gesamt, zuSehen, frei, daumenY, daumenPx) {
|
||||||
detectVerticalDragGestures(
|
awaitEachGesture {
|
||||||
onDragStart = {
|
// requireUnconsumed = false: der Druck darf schon durch andere gesehen worden
|
||||||
ziehen = true
|
// sein, wir wollen ihn trotzdem bewerten.
|
||||||
zielPos = state.firstVisibleItemIndex.toFloat()
|
val start = awaitFirstDown(requireUnconsumed = false)
|
||||||
},
|
val aufDemDaumen = start.position.y >= daumenY - spielPx &&
|
||||||
onDragEnd = { ziehen = false },
|
start.position.y <= daumenY + daumenPx + spielPx
|
||||||
onDragCancel = { ziehen = false },
|
if (!aufDemDaumen || frei <= 0f) return@awaitEachGesture // Liste scrollt normal
|
||||||
) { change, delta ->
|
start.consume()
|
||||||
change.consume()
|
ziehen = true
|
||||||
if (frei <= 0f) return@detectVerticalDragGestures
|
zielPos = state.firstVisibleItemIndex.toFloat()
|
||||||
zielPos = (zielPos + delta * maxIndex / frei).coerceIn(0f, maxIndex.toFloat())
|
try {
|
||||||
scope.launch { state.scrollToItem(zielPos.roundToInt()) }
|
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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
.offset { IntOffset(0, daumenY.roundToInt()) }
|
.offset { IntOffset(0, daumenY.roundToInt()) }
|
||||||
|
.padding(end = 2.dp)
|
||||||
.width(breite)
|
.width(breite)
|
||||||
.height(with(dichte) { daumenPx.toDp() })
|
.height(with(dichte) { daumenPx.toDp() })
|
||||||
.padding(horizontal = 2.dp)
|
|
||||||
.clip(RoundedCornerShape(50))
|
.clip(RoundedCornerShape(50))
|
||||||
.background(farbe.copy(alpha = alpha))
|
.background(farbe.copy(alpha = alpha))
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user