Settle the pinch at its clamp boundaries (#56)

Whole-pixel clamp bounds so a held pinch stops nudging the scroll, and
endPinch() in a finally so a cancelled gesture can't deafen the zoom to
Settings.
This commit is contained in:
2026-07-31 20:43:00 +02:00
parent 5bdc4a20b0
commit 7c1a03eac1
3 changed files with 91 additions and 33 deletions

View File

@@ -161,10 +161,20 @@ private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
TimelineScale.Comfortable to "Comfortable", TimelineScale.Comfortable to "Comfortable",
) )
/** Serialise for the `timeline_scale` preference; see [parseTimelineScale]. */ /**
* Serialise for the `timeline_scale` preference; see [parseTimelineScale].
*
* Spelled out rather than an `else` into [PRESET_NAMES], so a preset added later
* without a stored name is a compile error here instead of a crash the first
* time someone picks it.
*/
fun TimelineScale.storageValue(): String = when (this) { fun TimelineScale.storageValue(): String = when (this) {
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
else -> PRESET_NAMES.getValue(this) TimelineScale.FitDay,
TimelineScale.Compact,
TimelineScale.Regular,
TimelineScale.Comfortable,
-> PRESET_NAMES.getValue(this)
} }
/** /**

View File

@@ -19,6 +19,8 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -126,40 +128,52 @@ fun rememberTimelinePinchZoom(
// Scroll the layout could not give us yet, carried to the next frame // Scroll the layout could not give us yet, carried to the next frame
// (see anchoredScroll). // (see anchoredScroll).
var pending = 0f var pending = 0f
while (true) { try {
val event = awaitPointerEvent(PointerEventPass.Initial) while (true) {
if (event.changes.none { it.pressed }) break val event = awaitPointerEvent(PointerEventPass.Initial)
if (event.changes.count { it.pressed } >= 2) { if (event.changes.none { it.pressed }) break
val step = event.calculateZoom() if (event.changes.count { it.pressed } >= 2) {
if (!claimed) { val step = event.calculateZoom()
slop *= step if (!claimed) {
if (abs(slop - 1f) >= PINCH_SLOP) { slop *= step
claimed = true if (abs(slop - 1f) >= PINCH_SLOP) {
zoom.beginPinch() claimed = true
zoom.beginPinch()
}
}
if (claimed) {
val old = currentHourHeight.value.toPx()
val new = pinchedHourHeightPx(
target = old * step,
fillPx = fillHourHeight(currentViewport.value).toPx(),
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
)
// Half a pixel, not equality: the height round-trips
// through dp and back, and an exact test would read
// the float noise that comes back as a scale change
// and feed the scroll a delta on every frame of a
// held pinch.
if (abs(new - old) >= 0.5f) {
val centroidY = event.calculateCentroid(useCurrent = true).y
pending += anchoredScroll(scrollState.value, centroidY, old, new) -
scrollState.value
zoom.pinchTo(new.toDp())
}
pending -= scrollState.dispatchRawDelta(pending)
} }
} }
if (claimed) { // Hold the gesture to the end once it has become a pinch:
val old = currentHourHeight.value.toPx() // letting go the moment a finger lifts would turn the tail of
val new = pinchedHourHeightPx( // a zoom into a scroll, and the taps underneath into a new event.
target = old * step, if (claimed) event.changes.forEach { if (it.pressed) it.consume() }
fillPx = fillHourHeight(currentViewport.value).toPx(),
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
)
if (new != old) {
val centroidY = event.calculateCentroid(useCurrent = true).y
pending += anchoredScroll(scrollState.value, centroidY, old, new) -
scrollState.value
zoom.pinchTo(new.toDp())
}
pending -= scrollState.dispatchRawDelta(pending)
}
} }
// Hold the gesture to the end once it has become a pinch: letting } finally {
// go the moment a finger lifts would turn the tail of a zoom into // In a finally because the gesture can also end by having its
// a scroll, and the taps underneath into a new event. // pointer node disposed mid-pinch — the timeline swapping out
if (claimed) event.changes.forEach { if (it.pressed) it.consume() } // under the fingers. The zoom outlives that node, and a pinch
// left open would make it ignore every later Settings change.
if (claimed) zoom.endPinch()
} }
if (claimed) zoom.endPinch()
} }
} }
} }
@@ -175,11 +189,19 @@ fun rememberTimelinePinchZoom(
* the labels out at 56px and leaves the 23:00 label ~9px above the line it * the labels out at 56px and leaves the 23:00 label ~9px above the line it
* names, jumping the whole column as a pinch drifts across each half pixel. * names, jumping the whole column as a pinch drifts across each half pixel.
* Pinning the hour to whole pixels keeps every part of the timeline on one grid. * Pinning the hour to whole pixels keeps every part of the timeline on one grid.
*
* The bounds themselves are pulled onto that grid too, each in the direction
* that keeps its own promise — up for the fill floor, so no dead space opens
* under midnight, down for the ceiling. A fractional bound would be a height the
* pinch can be held against but never actually land on, and the difference feeds
* the focal anchor a scroll correction on every frame the fingers sit still.
*/ */
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float = internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
// Filling the viewport wins over the ceiling: on a screen tall enough for // Filling the viewport wins over the ceiling: on a screen tall enough for
// the two to disagree, dead space is the worse of the two failures. // the two to disagree, dead space is the worse of the two failures.
target.coerceAtMost(maxPx).coerceAtLeast(fillPx).roundToInt().toFloat() target.roundToInt().toFloat()
.coerceAtMost(floor(maxPx))
.coerceAtLeast(ceil(fillPx))
/** /**
* The scroll offset that keeps the moment under [centroidY] under it after the * The scroll offset that keeps the moment under [centroidY] under it after the

View File

@@ -74,6 +74,32 @@ class TimelineZoomTest {
assertThat(pinchedHourHeightPx(target = 9_000f, fillPx = 28f, maxPx = 240f)).isEqualTo(240f) assertThat(pinchedHourHeightPx(target = 9_000f, fillPx = 28f, maxPx = 240f)).isEqualTo(240f)
} }
@Test
fun `a pinch held against a fractional bound stays put`() {
// A bound that is not a whole pixel is a height the pinch can be pushed
// against but never land on, so every frame of a held gesture would look
// like a scale change and hand the focal anchor a scroll correction.
val fillPx = 62.083f
val maxPx = 616.5f
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
assertThat(floor).isEqualTo(63f)
assertThat(ceiling).isEqualTo(616f)
// Landing there and being pushed further must not move them again.
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
assertThat(pinchedHourHeightPx(ceiling * 1.1f, fillPx, maxPx)).isEqualTo(ceiling)
}
@Test
fun `the fill floor never leaves dead space under midnight`() {
// Rounding the floor down would open a gap the pinch cannot close.
val viewport = 1490f
val floor = pinchedHourHeightPx(target = 1f, fillPx = viewport / 24f, maxPx = 616f)
assertThat(floor * 24).isAtLeast(viewport)
}
@Test @Test
fun `a settled pinch is what gets persisted`() { fun `a settled pinch is what gets persisted`() {
var persisted: TimelineScale? = null var persisted: TimelineScale? = null