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.
|
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 8
|
versionCode = 9
|
||||||
versionName = "0.4.1"
|
versionName = "0.4.2"
|
||||||
|
|
||||||
// We provide our own instrumentation runner if/when tests are added.
|
// We provide our own instrumentation runner if/when tests are added.
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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.Canvas
|
||||||
import androidx.compose.foundation.background
|
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.detectTapGestures
|
||||||
|
import androidx.compose.foundation.gestures.scrollable
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
@@ -80,20 +84,41 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
|
|||||||
val velPx = trackPx * 0.25f
|
val velPx = trackPx * 0.25f
|
||||||
|
|
||||||
val visibleRows = (heightPx / rowPx).toInt().coerceAtLeast(1)
|
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
|
// Vertical scroll model. By default the view FOLLOWS — it auto-centres on the
|
||||||
// in composition) so it can be read from the draw phase during playback and
|
// playhead while playing, else on the cursor. A finger flick detaches it into
|
||||||
// from gestures at touch time, always against the live transport/cursor.
|
// free inertial scrolling; switching to another block re-attaches. Both states
|
||||||
fun firstRow(): Int {
|
// 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 t = transportState.value
|
||||||
val focusLine = if (t.isPlaying) t.currentLine else vm.cursorLine
|
val focusLine = if (t.isPlaying) t.currentLine else vm.cursorLine
|
||||||
return (focusLine - visibleRows / 2)
|
return (focusLine * rowPx + rowPx / 2f - heightPx / 2f).coerceIn(0f, maxScrollPx)
|
||||||
.coerceIn(0, (pattern.length - visibleRows).coerceAtLeast(0))
|
}
|
||||||
|
// 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>? {
|
fun hitTest(x: Float, y: Float): Triple<Int, CellColumn, Int>? {
|
||||||
if (x < gutterPx) return null
|
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 tx = x - gutterPx
|
||||||
val track = (tx / trackPx).toInt().coerceIn(0, Pattern.TRACK_COUNT - 1)
|
val track = (tx / trackPx).toInt().coerceIn(0, Pattern.TRACK_COUNT - 1)
|
||||||
val within = tx - track * trackPx
|
val within = tx - track * trackPx
|
||||||
@@ -108,6 +133,8 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
|
|||||||
Canvas(
|
Canvas(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
// Inertial vertical scrolling (fling), detaching the auto-follow.
|
||||||
|
.scrollable(orientation = Orientation.Vertical, state = scrollState)
|
||||||
.pointerInput(pattern, widthPx, heightPx) {
|
.pointerInput(pattern, widthPx, heightPx) {
|
||||||
// Tap moves the cursor to the cell/column. Note entry is done with
|
// Tap moves the cursor to the cell/column. Note entry is done with
|
||||||
// the punch-in keyboard or external controllers — there is no
|
// 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
|
// scroll position. A change to either invalidates THIS DRAW only — no
|
||||||
// recomposition, no relayout — which is what keeps the playhead smooth.
|
// recomposition, no relayout — which is what keeps the playhead smooth.
|
||||||
val transport = transportState.value
|
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).
|
// Selection rectangle (draw-phase reads → redraw as the cursor drags it).
|
||||||
val selActive = vm.hasSelection
|
val selActive = vm.hasSelection
|
||||||
val selTracks = vm.selTrackRange
|
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
|
drawRect(c.grid, Offset(x0, 0f), Size(1f, heightPx)) // separator line
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Rows: beat/bar/playhead backgrounds + cursor + text.
|
// 2) Rows: beat/bar/playhead backgrounds + cursor + text. One extra row so
|
||||||
for (screen in 0 until visibleRows) {
|
// the partially-scrolled bottom row is filled; y may start slightly above 0.
|
||||||
val line = top + screen
|
for (screen in 0..visibleRows) {
|
||||||
|
val line = firstLine + screen
|
||||||
if (line >= pattern.length) break
|
if (line >= pattern.length) break
|
||||||
val y = screen * rowPx
|
if (line < 0) continue
|
||||||
|
val y = yShift + screen * rowPx
|
||||||
|
|
||||||
val rowColor = when {
|
val rowColor = when {
|
||||||
transport.isPlaying && line == transport.currentLine -> c.playhead.copy(alpha = 0.22f)
|
transport.isPlaying && line == transport.currentLine -> c.playhead.copy(alpha = 0.22f)
|
||||||
|
|||||||
Reference in New Issue
Block a user