#756 improve swipe gesture

This commit is contained in:
lethemanh
2026-04-16 17:33:13 +07:00
committed by Benoit TELLIER
parent 3d07008a8c
commit fd2282a65f
3 changed files with 110 additions and 88 deletions
+6 -10
View File
@@ -377,12 +377,11 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
errorHandler: errorHandler.current errorHandler: errorHandler.current
}) })
const { offsetX, isAnimating, onTouchStart, onTouchMove, onTouchEnd } = const { offsetX, isAnimating } = useSwipeCalendar({
useSwipeCalendar({ calendarRef,
calendarRef, containerRef: calendarWrapperRef,
containerRef: calendarWrapperRef, isMobile
isMobile })
})
useEffect(() => { useEffect(() => {
if (process.env.NODE_ENV === 'test') { if (process.env.NODE_ENV === 'test') {
@@ -441,10 +440,7 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
<div <div
ref={calendarWrapperRef} ref={calendarWrapperRef}
className="calendar-viewport" className="calendar-viewport"
onTouchStart={onTouchStart} style={{ height: '100%', touchAction: 'pan-y' }}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
style={{ height: '100%' }}
> >
<div <div
className={`calendar-track ${isAnimating ? 'calendar-track--animate' : ''}`} className={`calendar-track ${isAnimating ? 'calendar-track--animate' : ''}`}
@@ -17,28 +17,17 @@ export const useSwipeAnimation = (
const finalizeSwipe = useCallback( const finalizeSwipe = useCallback(
(targetOffset: number, deltaX: number) => { (targetOffset: number, deltaX: number) => {
setIsAnimating(true) setIsAnimating(true)
setOffsetX(targetOffset)
if (deltaX > 0) {
calendarRef.current?.prev()
} else {
calendarRef.current?.next()
}
setOffsetX(0)
setTimeout(() => { setTimeout(() => {
setIsAnimating(false) setIsAnimating(false)
setOffsetX(-targetOffset) }, 300)
// Apply date change
if (deltaX > 0) {
calendarRef.current?.prev()
} else {
calendarRef.current?.next()
}
// Slide back to center (requesting animation state)
requestAnimationFrame(() => {
setIsAnimating(true)
setOffsetX(0)
})
// Immediate reset to preserve "snappy" behavior as per latest user tweak
setIsAnimating(false)
}, 100)
}, },
[calendarRef] [calendarRef]
) )
@@ -1,10 +1,4 @@
import { import { useRef, useState, useEffect, MutableRefObject, RefObject } from 'react'
useRef,
useState,
useCallback,
MutableRefObject,
RefObject
} from 'react'
import { CalendarApi } from '@fullcalendar/core' import { CalendarApi } from '@fullcalendar/core'
import { useSwipeAnimation } from './useSwipeAnimation' import { useSwipeAnimation } from './useSwipeAnimation'
@@ -17,25 +11,70 @@ interface UseSwipeCalendarProps {
interface SwipeCalendarReturn { interface SwipeCalendarReturn {
offsetX: number offsetX: number
isAnimating: boolean isAnimating: boolean
onTouchStart: (e: React.TouchEvent) => void
onTouchMove: (e: React.TouchEvent) => void
onTouchEnd: () => void
} }
const isHorizontalSwipe = (deltaX: number, deltaY: number): boolean => { const isHorizontalSwipe = (dx: number, dy: number): boolean =>
return Math.abs(deltaX) > Math.abs(deltaY) || Math.abs(deltaX) > 10 Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 10
}
const getTargetOffset = ( const getSwipeTarget = (
deltaX: number, offsetX: number,
containerWidth: number, containerWidth: number
threshold: number
): number | null => { ): number | null => {
if (Math.abs(deltaX) <= threshold) return null if (Math.abs(offsetX) <= containerWidth * 0.1) return null
return deltaX > 0 ? containerWidth : -containerWidth return offsetX > 0 ? containerWidth : -containerWidth
} }
const preventSwipping = (): void => {} const handleTouchMove = (
e: TouchEvent,
touchStartRef: React.MutableRefObject<{ x: number; y: number } | null>,
isHorizontalRef: React.MutableRefObject<boolean>,
setOffsetX: (dx: number) => void
): void => {
if (!touchStartRef.current) return
const dx = e.touches[0].clientX - touchStartRef.current.x
const dy = e.touches[0].clientY - touchStartRef.current.y
if (!isHorizontalRef.current)
isHorizontalRef.current = isHorizontalSwipe(dx, dy)
if (isHorizontalRef.current && e.cancelable) {
e.preventDefault()
setOffsetX(dx)
}
}
const handleTouchEnd = ({
touchStartRef,
isHorizontalRef,
calendarRef,
offsetX,
containerWidth,
setOffsetX,
finalizeSwipe,
cancelSwipe
}: {
touchStartRef: React.MutableRefObject<{ x: number; y: number } | null>
isHorizontalRef: React.MutableRefObject<boolean>
calendarRef: MutableRefObject<CalendarApi | null>
offsetX: number
containerWidth: number
setOffsetX: (dx: number) => void
finalizeSwipe: (target: number, currentOffset: number) => void
cancelSwipe: () => void
}): void => {
const hasValidTouch = touchStartRef.current && calendarRef.current
if (!hasValidTouch) {
setOffsetX(0)
touchStartRef.current = null
isHorizontalRef.current = false
return
}
if (isHorizontalRef.current) {
const target = getSwipeTarget(offsetX, containerWidth)
if (target !== null) finalizeSwipe(target, offsetX)
else cancelSwipe()
}
touchStartRef.current = null
isHorizontalRef.current = false
}
export const useSwipeCalendar = ({ export const useSwipeCalendar = ({
calendarRef, calendarRef,
@@ -46,57 +85,55 @@ export const useSwipeCalendar = ({
useSwipeAnimation(calendarRef) useSwipeAnimation(calendarRef)
const [containerWidth, setContainerWidth] = useState(0) const [containerWidth, setContainerWidth] = useState(0)
const touchStartRef = useRef<{ x: number; y: number } | null>(null) const touchStartRef = useRef<{ x: number; y: number } | null>(null)
const isHorizontalRef = useRef<boolean>(false)
const onTouchStart = useCallback( useEffect(() => {
(e: React.TouchEvent) => { const el = containerRef.current
if (containerRef.current) if (!el || !isMobile) return
setContainerWidth(containerRef.current.getBoundingClientRect().width)
const onTouchStart = (e: TouchEvent): void => {
setContainerWidth(el.getBoundingClientRect().width)
touchStartRef.current = { touchStartRef.current = {
x: e.touches[0].clientX, x: e.touches[0].clientX,
y: e.touches[0].clientY y: e.touches[0].clientY
} }
}, isHorizontalRef.current = false
[containerRef] }
)
const onTouchMove = useCallback( const onTouchMove = (e: TouchEvent): void =>
(e: React.TouchEvent) => { handleTouchMove(e, touchStartRef, isHorizontalRef, setOffsetX)
if (!touchStartRef.current) return
const dx = e.touches[0].clientX - touchStartRef.current.x
const dy = e.touches[0].clientY - touchStartRef.current.y
if (isHorizontalSwipe(dx, dy)) setOffsetX(dx)
},
[setOffsetX]
)
const onTouchEnd = useCallback(() => { const onTouchEnd = (): void =>
if (!touchStartRef.current || !calendarRef.current) return setOffsetX(0) handleTouchEnd({
const target = getTargetOffset( touchStartRef,
offsetX, isHorizontalRef,
containerWidth, calendarRef,
containerWidth * 0.1 offsetX,
) containerWidth,
if (target !== null) finalizeSwipe(target, offsetX) setOffsetX,
else cancelSwipe() finalizeSwipe,
touchStartRef.current = null cancelSwipe
})
el.addEventListener('touchstart', onTouchStart, { passive: true })
el.addEventListener('touchmove', onTouchMove, { passive: false })
el.addEventListener('touchend', onTouchEnd, { passive: true })
return (): void => {
el.removeEventListener('touchstart', onTouchStart)
el.removeEventListener('touchmove', onTouchMove)
el.removeEventListener('touchend', onTouchEnd)
}
}, [ }, [
calendarRef, containerRef,
isMobile,
offsetX, offsetX,
containerWidth, containerWidth,
setOffsetX,
finalizeSwipe, finalizeSwipe,
cancelSwipe, cancelSwipe,
setOffsetX calendarRef
]) ])
if (!isMobile) { return { offsetX, isAnimating }
return {
offsetX: 0,
isAnimating: false,
onTouchStart: preventSwipping,
onTouchMove: preventSwipping,
onTouchEnd: preventSwipping
}
}
return { offsetX, isAnimating, onTouchStart, onTouchMove, onTouchEnd }
} }