web smooth scrolling
This commit is contained in:
@@ -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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user