Add overscroll

This commit is contained in:
Eric Bailey
2026-04-15 15:38:16 -05:00
parent a7ba9dcbbb
commit 7cdfc6c933
2 changed files with 98 additions and 47 deletions
+3 -1
View File
@@ -19,7 +19,9 @@ export function tween(start: number, end: number, duration: number) {
const te = t - ts
const next = Math.round(ease(te, start, end - start, duration))
if (
(end > start ? next < end && last <= end : next > end && last >= end) &&
(end > start
? next < end && last <= end
: next > end && last >= end) &&
te <= duration
) {
frame = tick(next)
@@ -11,6 +11,8 @@ const FLICK_MIN_VELOCITY = 0.1
const ADVANCE_THRESHOLD = 0.15
const FRAME_MS = 1000 / 60
const SETTLE_DURATION = 600
const OVERSCROLL_RESISTANCE = 0.3
const BOUNCE_DURATION = 400
function whichByDistance(
itemWidths: Map<number, number>,
@@ -70,9 +72,15 @@ export function usePointerHandlers({
let t = 0
let stopTween: (() => void) | null = null
let localIndex = currentIndexRef.current
let overscrollX = 0
el.style.cursor = 'grab'
const clearOverscroll = () => {
overscrollX = 0
el.style.transform = ''
}
const onMouseDown = (e: MouseEvent) => {
e.preventDefault() // prevent native image drag
@@ -81,6 +89,7 @@ export function usePointerHandlers({
stopTween()
stopTween = null
}
clearOverscroll()
isMouseDown = true
isDragging = false
@@ -121,19 +130,38 @@ export function usePointerHandlers({
velo = (delta - prevDelta) / (elapsed * FRAME_MS)
t = e.timeStamp
scrollTo(dragScrollLeft - delta)
const desiredScroll = dragScrollLeft - delta
const maxScroll = el.scrollWidth - el.clientWidth
// Update local index from scroll position
const offsetX = dragScrollLeft - delta
let accumulated = 0
for (let i = 0; i < imageCount; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (offsetX < accumulated + w / 2) {
localIndex = i
break
if (desiredScroll < 0) {
// Overscroll at start — rubber band
scrollTo(0)
overscrollX = desiredScroll * OVERSCROLL_RESISTANCE
el.style.transform = `translateX(${-overscrollX}px)`
} else if (desiredScroll > maxScroll) {
// Overscroll at end — rubber band
scrollTo(maxScroll)
overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE
el.style.transform = `translateX(${-overscrollX}px)`
} else {
// Normal scroll range
scrollTo(desiredScroll)
if (overscrollX !== 0) clearOverscroll()
}
// Update local index from scroll position (only in normal range)
if (overscrollX === 0) {
const offsetX = desiredScroll
let accumulated = 0
for (let i = 0; i < imageCount; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (offsetX < accumulated + w / 2) {
localIndex = i
break
}
accumulated += w
if (i === imageCount - 1) localIndex = i
}
accumulated += w
if (i === imageCount - 1) localIndex = i
}
}
@@ -154,47 +182,67 @@ export function usePointerHandlers({
capture: true,
})
// Estimate resting distance from velocity
let v = Math.abs(velo)
let restingDistance = 0
while (v > FLICK_MIN_VELOCITY) {
v *= FLICK_DECAY
restingDistance += v
}
if (overscrollX !== 0) {
// Bounce back from overscroll
const targetIndex = overscrollX > 0 ? imageCount - 1 : 0
const fromOverscroll = overscrollX
const direction: -1 | 1 = delta < 0 ? -1 : 1
const totalDistance = Math.abs(delta) + restingDistance
stopTween = tween(
fromOverscroll,
0,
BOUNCE_DURATION,
)(
v => {
el.style.transform = `translateX(${-v}px)`
},
() => {
stopTween = null
clearOverscroll()
onSettle(targetIndex)
},
)
} else {
// Normal flick settle
let v = Math.abs(velo)
let restingDistance = 0
while (v > FLICK_MIN_VELOCITY) {
v *= FLICK_DECAY
restingDistance += v
}
const targetIndex = whichByDistance(
itemWidthsRef.current,
localIndex,
totalDistance,
direction,
imageCount,
)
const direction: -1 | 1 = delta < 0 ? -1 : 1
const totalDistance = Math.abs(delta) + restingDistance
// Tween from current scroll position to target
const from = el.scrollLeft
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
const targetIndex = whichByDistance(
itemWidthsRef.current,
localIndex,
totalDistance,
direction,
imageCount,
)
if (from === to) {
onSettle(targetIndex)
return
}
const from = el.scrollLeft
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
stopTween = tween(
from,
to,
SETTLE_DURATION,
)(
v => {
scrollTo(v)
},
() => {
stopTween = null
if (from === to) {
onSettle(targetIndex)
},
)
return
}
stopTween = tween(
from,
to,
SETTLE_DURATION,
)(
v => {
scrollTo(v)
},
() => {
stopTween = null
onSettle(targetIndex)
},
)
}
}
}
@@ -207,6 +255,7 @@ export function usePointerHandlers({
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
if (stopTween) stopTween()
clearOverscroll()
el.style.cursor = ''
el.style.userSelect = ''
}