Inertial touch scrolling for the tracker grid (v0.4.2)

Add fling/drag vertical scrolling to the pattern grid via Modifier.scrollable
(fractional pixel offset for smooth, non-snapping motion). The view still follows
the playhead during playback (and the cursor when stopped) by default; a finger
flick detaches it into free inertial scrolling, and switching to another block
re-attaches the follow (the scroll state is keyed on the active pattern).
Tap-to-focus is unchanged. Patch bump to 0.4.2 (versionCode 9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 01:15:02 +02:00
parent 63cad6897f
commit 74d60993f0
2 changed files with 47 additions and 14 deletions

View File

@@ -22,8 +22,8 @@ android {
// (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26
targetSdk = 34
versionCode = 8
versionName = "0.4.1"
versionCode = 9
versionName = "0.4.2"
// We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -5,11 +5,15 @@ package space.rcmd.android.sizzle.ui.tracker
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@@ -80,20 +84,41 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
val velPx = trackPx * 0.25f
val visibleRows = (heightPx / rowPx).toInt().coerceAtLeast(1)
val maxScrollPx = (pattern.length * rowPx - heightPx).coerceAtLeast(0f)
// Which row sits at the top of the viewport. Computed on demand (not stored
// in composition) so it can be read from the draw phase during playback and
// from gestures at touch time, always against the live transport/cursor.
fun firstRow(): Int {
// Vertical scroll model. By default the view FOLLOWS — it auto-centres on the
// playhead while playing, else on the cursor. A finger flick detaches it into
// free inertial scrolling; switching to another block re-attaches. Both states
// are keyed on `pattern`, so selecting a different block re-inits them (== the
// "resume following on block switch" behaviour).
val following = remember(pattern) { mutableStateOf(true) }
val scrollPx = remember(pattern) { mutableStateOf(0f) }
// Pixel scroll offset the view auto-follows (top of viewport, 0 == line 0).
fun autoScrollPx(): Float {
val t = transportState.value
val focusLine = if (t.isPlaying) t.currentLine else vm.cursorLine
return (focusLine - visibleRows / 2)
.coerceIn(0, (pattern.length - visibleRows).coerceAtLeast(0))
return (focusLine * rowPx + rowPx / 2f - heightPx / 2f).coerceIn(0f, maxScrollPx)
}
// The offset actually shown: the followed one, or the free one once detached.
fun effScrollPx(): Float =
if (following.value) autoScrollPx() else scrollPx.value.coerceIn(0f, maxScrollPx)
// Fling/drag scrolling. The first delta seeds the free position from wherever
// the followed view currently sits, then detaches following.
val scrollState = remember(pattern, rowPx, heightPx, maxScrollPx) {
ScrollableState { delta ->
val old = if (following.value) autoScrollPx() else scrollPx.value
following.value = false
val next = (old - delta).coerceIn(0f, maxScrollPx)
scrollPx.value = next
old - next // consumed (partial at the ends stops the fling)
}
}
fun hitTest(x: Float, y: Float): Triple<Int, CellColumn, Int>? {
if (x < gutterPx) return null
val line = (firstRow() + (y / rowPx).toInt()).coerceIn(0, pattern.length - 1)
val line = ((effScrollPx() + y) / rowPx).toInt().coerceIn(0, pattern.length - 1)
val tx = x - gutterPx
val track = (tx / trackPx).toInt().coerceIn(0, Pattern.TRACK_COUNT - 1)
val within = tx - track * trackPx
@@ -108,6 +133,8 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
Canvas(
Modifier
.fillMaxSize()
// Inertial vertical scrolling (fling), detaching the auto-follow.
.scrollable(orientation = Orientation.Vertical, state = scrollState)
.pointerInput(pattern, widthPx, heightPx) {
// Tap moves the cursor to the cell/column. Note entry is done with
// the punch-in keyboard or external controllers — there is no
@@ -127,7 +154,11 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
// scroll position. A change to either invalidates THIS DRAW only — no
// recomposition, no relayout — which is what keeps the playhead smooth.
val transport = transportState.value
val top = firstRow()
// Fractional scroll: the first visible line plus a sub-row pixel shift so
// flinging scrolls smoothly rather than snapping row-by-row.
val scroll = effScrollPx()
val firstLine = (scroll / rowPx).toInt()
val yShift = -(scroll - firstLine * rowPx)
// Selection rectangle (draw-phase reads → redraw as the cursor drags it).
val selActive = vm.hasSelection
val selTracks = vm.selTrackRange
@@ -142,11 +173,13 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
drawRect(c.grid, Offset(x0, 0f), Size(1f, heightPx)) // separator line
}
// 2) Rows: beat/bar/playhead backgrounds + cursor + text.
for (screen in 0 until visibleRows) {
val line = top + screen
// 2) Rows: beat/bar/playhead backgrounds + cursor + text. One extra row so
// the partially-scrolled bottom row is filled; y may start slightly above 0.
for (screen in 0..visibleRows) {
val line = firstLine + screen
if (line >= pattern.length) break
val y = screen * rowPx
if (line < 0) continue
val y = yShift + screen * rowPx
val rowColor = when {
transport.isPlaying && line == transport.currentLine -> c.playhead.copy(alpha = 0.22f)