Web in a good place
This commit is contained in:
@@ -28,6 +28,7 @@ import {Text} from '#/components/Typography'
|
|||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||||
import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers'
|
import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers'
|
||||||
|
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
|
||||||
|
|
||||||
const CONTAINER_ASPECT_RATIO = 3 / 2
|
const CONTAINER_ASPECT_RATIO = 3 / 2
|
||||||
const ITEM_GAP = 8 // tokens.space.sm
|
const ITEM_GAP = 8 // tokens.space.sm
|
||||||
@@ -152,6 +153,17 @@ export function Gallery({
|
|||||||
imageCount: images.length,
|
imageCount: images.length,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
usePointerHandlers({
|
||||||
|
flatListRef,
|
||||||
|
itemWidthsRef,
|
||||||
|
currentIndexRef,
|
||||||
|
onSettle(index: number) {
|
||||||
|
const el = itemRefsRef.current.get(index) as unknown as HTMLElement | null
|
||||||
|
el?.focus({preventScroll: true})
|
||||||
|
},
|
||||||
|
imageCount: images.length,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
function ease(t: number, b: number, c: number, d: number) {
|
||||||
|
return t === d ? b + c : c * (-Math.pow(2, (-10 * t) / d) + 1) + b
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tween from `start` to `end` over `duration` ms using an exponential ease-out.
|
||||||
|
* Returns a function that starts the tween. That function returns a stop handle.
|
||||||
|
*
|
||||||
|
* Adapted from tinkerbell.
|
||||||
|
*/
|
||||||
|
export function tween(start: number, end: number, duration: number) {
|
||||||
|
return function run(cb: (v: number) => void, done?: () => void) {
|
||||||
|
let ts: number | undefined
|
||||||
|
let frame: number
|
||||||
|
|
||||||
|
frame = (function tick(last: number) {
|
||||||
|
return requestAnimationFrame(t => {
|
||||||
|
if (!ts) ts = t
|
||||||
|
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) &&
|
||||||
|
te <= duration
|
||||||
|
) {
|
||||||
|
frame = tick(next)
|
||||||
|
cb(next)
|
||||||
|
} else {
|
||||||
|
cb(end)
|
||||||
|
done?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})(start)
|
||||||
|
|
||||||
|
return function stop() {
|
||||||
|
cancelAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function usePointerHandlers(_args: {
|
||||||
|
flatListRef: any
|
||||||
|
itemWidthsRef: any
|
||||||
|
currentIndexRef: any
|
||||||
|
onSettle: any
|
||||||
|
imageCount: any
|
||||||
|
}) {}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import {useEffect} from 'react'
|
||||||
|
import {type FlatList} from 'react-native'
|
||||||
|
|
||||||
|
import {tween} from '#/components/images/Gallery/tween'
|
||||||
|
|
||||||
|
const ITEM_GAP = 8
|
||||||
|
const DRAG_THRESHOLD = 3
|
||||||
|
const FLICK_DECAY = 0.85
|
||||||
|
const FLICK_MIN_VELOCITY = 0.1
|
||||||
|
const ADVANCE_THRESHOLD = 0.15
|
||||||
|
const FRAME_MS = 1000 / 60
|
||||||
|
const SETTLE_DURATION = 600
|
||||||
|
|
||||||
|
function getOffsetForIndex(
|
||||||
|
itemWidths: Map<number, number>,
|
||||||
|
index: number,
|
||||||
|
): number {
|
||||||
|
let offset = 0
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
offset += (itemWidths.get(i) ?? 0) + ITEM_GAP
|
||||||
|
}
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
|
||||||
|
function whichByDistance(
|
||||||
|
itemWidths: Map<number, number>,
|
||||||
|
currentIndex: number,
|
||||||
|
distance: number,
|
||||||
|
direction: -1 | 1,
|
||||||
|
imageCount: number,
|
||||||
|
): number {
|
||||||
|
let remaining = distance
|
||||||
|
let i = currentIndex
|
||||||
|
|
||||||
|
while (remaining > 0 && i >= 0 && i < imageCount) {
|
||||||
|
const w = (itemWidths.get(i) ?? 0) + ITEM_GAP
|
||||||
|
if (remaining > w) {
|
||||||
|
remaining -= w
|
||||||
|
i -= direction
|
||||||
|
} else if (remaining > w * ADVANCE_THRESHOLD) {
|
||||||
|
i -= direction
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(i, imageCount - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePointerHandlers({
|
||||||
|
flatListRef,
|
||||||
|
itemWidthsRef,
|
||||||
|
currentIndexRef,
|
||||||
|
onSettle,
|
||||||
|
imageCount,
|
||||||
|
}: {
|
||||||
|
flatListRef: React.RefObject<FlatList | null>
|
||||||
|
itemWidthsRef: React.RefObject<Map<number, number>>
|
||||||
|
currentIndexRef: React.RefObject<number>
|
||||||
|
onSettle: (index: number) => void
|
||||||
|
imageCount: number
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (imageCount <= 1) return
|
||||||
|
|
||||||
|
const el =
|
||||||
|
flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
let isDragging = false
|
||||||
|
let isMouseDown = false
|
||||||
|
let startX = 0
|
||||||
|
let dragScrollLeft = 0
|
||||||
|
let delta = 0
|
||||||
|
let prevDelta = 0
|
||||||
|
let velo = 0
|
||||||
|
let t = 0
|
||||||
|
let stopTween: (() => void) | null = null
|
||||||
|
|
||||||
|
el.style.cursor = 'grab'
|
||||||
|
|
||||||
|
const onMouseDown = (e: MouseEvent) => {
|
||||||
|
e.preventDefault() // prevent native image drag
|
||||||
|
|
||||||
|
// Cancel any in-progress tween
|
||||||
|
if (stopTween) {
|
||||||
|
stopTween()
|
||||||
|
stopTween = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isMouseDown = true
|
||||||
|
isDragging = false
|
||||||
|
startX = e.pageX
|
||||||
|
dragScrollLeft = el.scrollLeft
|
||||||
|
delta = 0
|
||||||
|
prevDelta = 0
|
||||||
|
velo = 0
|
||||||
|
t = e.timeStamp
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isMouseDown) return
|
||||||
|
|
||||||
|
const x = e.pageX - startX
|
||||||
|
|
||||||
|
// Require minimum movement before starting drag
|
||||||
|
if (!isDragging && Math.abs(x) < DRAG_THRESHOLD) return
|
||||||
|
|
||||||
|
if (!isDragging) {
|
||||||
|
isDragging = true
|
||||||
|
el.style.cursor = 'grabbing'
|
||||||
|
el.style.userSelect = 'none'
|
||||||
|
|
||||||
|
// Blur focused element within the gallery
|
||||||
|
if (el.contains(document.activeElement)) {
|
||||||
|
;(document.activeElement as HTMLElement)?.blur?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
// Track velocity
|
||||||
|
const elapsed = e.timeStamp - t || 1
|
||||||
|
prevDelta = delta
|
||||||
|
delta = x
|
||||||
|
velo = (delta - prevDelta) / (elapsed * FRAME_MS)
|
||||||
|
t = e.timeStamp
|
||||||
|
|
||||||
|
el.scrollLeft = dragScrollLeft - delta
|
||||||
|
|
||||||
|
// Update current index from scroll position
|
||||||
|
const offsetX = el.scrollLeft
|
||||||
|
let accumulated = 0
|
||||||
|
for (let i = 0; i < imageCount; i++) {
|
||||||
|
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
|
||||||
|
if (offsetX < accumulated + w / 2) {
|
||||||
|
currentIndexRef.current = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
accumulated += w
|
||||||
|
if (i === imageCount - 1) currentIndexRef.current = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
if (!isMouseDown) return
|
||||||
|
|
||||||
|
const wasDragging = isDragging
|
||||||
|
isMouseDown = false
|
||||||
|
isDragging = false
|
||||||
|
|
||||||
|
el.style.cursor = 'grab'
|
||||||
|
el.style.userSelect = ''
|
||||||
|
|
||||||
|
if (wasDragging) {
|
||||||
|
// Suppress the click that follows mouseup after a drag
|
||||||
|
el.addEventListener('click', e => e.stopPropagation(), {
|
||||||
|
once: true,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction: -1 | 1 = delta < 0 ? -1 : 1
|
||||||
|
const totalDistance = Math.abs(delta) + restingDistance
|
||||||
|
|
||||||
|
const targetIndex = whichByDistance(
|
||||||
|
itemWidthsRef.current,
|
||||||
|
currentIndexRef.current,
|
||||||
|
totalDistance,
|
||||||
|
direction,
|
||||||
|
imageCount,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tween from current scroll position to target
|
||||||
|
const from = el.scrollLeft
|
||||||
|
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
|
||||||
|
|
||||||
|
stopTween = tween(from, to, SETTLE_DURATION)(
|
||||||
|
v => {
|
||||||
|
el.scrollLeft = v
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
stopTween = null
|
||||||
|
currentIndexRef.current = targetIndex
|
||||||
|
onSettle(targetIndex)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('mousedown', onMouseDown)
|
||||||
|
window.addEventListener('mousemove', onMouseMove)
|
||||||
|
window.addEventListener('mouseup', onMouseUp)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('mousedown', onMouseDown)
|
||||||
|
window.removeEventListener('mousemove', onMouseMove)
|
||||||
|
window.removeEventListener('mouseup', onMouseUp)
|
||||||
|
if (stopTween) stopTween()
|
||||||
|
el.style.cursor = ''
|
||||||
|
el.style.userSelect = ''
|
||||||
|
}
|
||||||
|
}, [flatListRef, itemWidthsRef, currentIndexRef, onSettle, imageCount])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user