web smooth scrolling

This commit is contained in:
vineyardbovines
2026-04-02 09:10:51 -04:00
parent 319ba37a11
commit af3be9aa76
+64 -91
View File
@@ -1,4 +1,4 @@
import {useRef, useState} from 'react' import {useEffect, useRef, useState} from 'react'
import {Pressable, ScrollView, View} from 'react-native' import {Pressable, ScrollView, View} from 'react-native'
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image} from 'expo-image'
@@ -72,104 +72,84 @@ export function Gallery({
return containerWidth return containerWidth
} }
// Click-and-drag scrolling // Click-and-drag scrolling via DOM listeners
const isDragging = useRef(false)
const dragStartX = useRef(0)
const dragScrollStart = useRef(0)
const hasDragged = useRef(false) const hasDragged = useRef(false)
const scrollNodeRef = useRef<HTMLElement | null>(null)
const onScrollViewRef = (ref: ScrollView | null) => { useEffect(() => {
scrollRef.current = ref const el = scrollRef.current as unknown as HTMLElement
if (!ref) return
const scrollable = ref as unknown as {
getScrollableNode?: () => HTMLElement
}
const el =
scrollable.getScrollableNode?.() ?? (ref as unknown as HTMLElement)
scrollNodeRef.current = el
if (!el) return if (!el) return
// Attach click-and-drag listeners directly to the scroll DOM node let isDragging = false
let startX = 0
let scrollStart = 0
let prevX = 0
let prevTime = 0
let velocity = 0
let momentumId = 0
el.style.cursor = 'grab' el.style.cursor = 'grab'
let lastMoveX = 0
let lastMoveTime = 0
el.addEventListener('mousedown', (e: MouseEvent) => { const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return if (e.button !== 0) return
isDragging.current = true cancelAnimationFrame(momentumId)
hasDragged.current = true // assume drag; clear on mouseup if no movement isDragging = true
dragStartX.current = e.clientX hasDragged.current = false
dragScrollStart.current = el.scrollLeft startX = e.pageX - el.offsetLeft
lastMoveX = e.clientX scrollStart = el.scrollLeft
lastMoveTime = Date.now() prevX = e.pageX
el.style.scrollSnapType = 'none' prevTime = Date.now()
el.style.scrollBehavior = 'auto' velocity = 0
el.style.cursor = 'grabbing' el.style.cursor = 'grabbing'
el.style.userSelect = 'none' e.preventDefault() // prevents native image drag
e.preventDefault() }
})
el.addEventListener('mousemove', (e: MouseEvent) => { const onMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return if (!isDragging) return
lastMoveX = e.clientX const x = e.pageX - el.offsetLeft
lastMoveTime = Date.now() if (Math.abs(x - startX) > 3) {
el.scrollLeft = dragScrollStart.current - (e.clientX - dragStartX.current) hasDragged.current = true
})
const onUp = (e: MouseEvent) => {
if (!isDragging.current) return
isDragging.current = false
// Only allow click-through if mouse barely moved (true click)
if (Math.abs(e.clientX - dragStartX.current) <= 3) {
hasDragged.current = false
} }
// Track velocity from recent movement
const now = Date.now()
const dt = now - prevTime
if (dt > 0) {
velocity = (e.pageX - prevX) / dt
}
prevX = e.pageX
prevTime = now
el.scrollLeft = scrollStart - (x - startX)
}
// Apply momentum: coast based on release velocity, then re-enable snap const onMouseUp = () => {
const dt = Date.now() - lastMoveTime if (!isDragging) return
const velocity = if (hasDragged.current) {
dt < 100 ? (lastMoveX - dragStartX.current) / Math.max(dt, 1) : 0 el.addEventListener('click', e => e.stopPropagation(), {once: true})
const momentum = velocity * -800 // scale velocity to scroll distance }
isDragging = false
el.style.scrollBehavior = 'smooth'
el.scrollLeft += momentum
// Re-enable snap after momentum scroll settles
requestAnimationFrame(() => {
el.style.scrollSnapType = 'x proximity'
})
el.style.cursor = 'grab' el.style.cursor = 'grab'
el.style.userSelect = ''
// Apply momentum with friction
const friction = 0.95
let v = -velocity * 15 // scale velocity to px/frame
const coast = () => {
if (Math.abs(v) < 0.5) return
el.scrollLeft += v
v *= friction
momentumId = requestAnimationFrame(coast)
}
coast()
} }
el.addEventListener('mouseup', onUp) el.addEventListener('mousedown', onMouseDown)
el.addEventListener('mouseleave', onUp as EventListener) window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
// Intercept click events during/after drag to prevent Pressable onPress return () => {
el.addEventListener( el.removeEventListener('mousedown', onMouseDown)
'click', window.removeEventListener('mousemove', onMouseMove)
(e: MouseEvent) => { window.removeEventListener('mouseup', onMouseUp)
if (hasDragged.current) {
e.stopPropagation()
e.preventDefault()
}
},
true, // capture phase — runs before React's synthetic events
)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
const node = scrollNodeRef.current
if (!node) return
if (e.key === 'ArrowLeft') {
e.preventDefault()
node.scrollLeft -= 200
} else if (e.key === 'ArrowRight') {
e.preventDefault()
node.scrollLeft += 200
} }
} }, [containerWidth]) // re-attach when scroll view mounts
return ( return (
<View <View
@@ -184,15 +164,12 @@ export function Gallery({
setContainerWidth(w) setContainerWidth(w)
} }
}} }}
// @ts-expect-error web-only prop
onKeyDown={handleKeyDown}
tabIndex={0}
role="group" role="group"
aria-roledescription="carousel" aria-roledescription="carousel"
aria-label={_(msg`Image gallery, ${images.length} images`)}> aria-label={_(msg`Image gallery, ${images.length} images`)}>
{containerWidth > 0 && ( {containerWidth > 0 && (
<ScrollView <ScrollView
ref={onScrollViewRef} ref={scrollRef}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
scrollEventThrottle={16} scrollEventThrottle={16}
@@ -201,8 +178,6 @@ export function Gallery({
height: containerHeight, height: containerHeight,
}, },
web({ web({
scrollSnapType: 'x proximity',
scrollBehavior: 'smooth',
WebkitOverflowScrolling: 'touch', WebkitOverflowScrolling: 'touch',
}), }),
]} ]}
@@ -241,7 +216,6 @@ export function Gallery({
width: getItemWidth(image), width: getItemWidth(image),
height: containerHeight, height: containerHeight,
}, },
web({scrollSnapAlign: 'start'}),
]} ]}
aria-roledescription="slide" aria-roledescription="slide"
aria-label={ aria-label={
@@ -275,7 +249,6 @@ export function Gallery({
a.rounded_md, a.rounded_md,
a.overflow_hidden, a.overflow_hidden,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
web({cursor: 'grab'}),
]}> ]}>
<Image <Image
source={{uri: image.thumb}} source={{uri: image.thumb}}