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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user