diff --git a/docs/docs/examples-advanced/responsive-slider.mdx b/docs/docs/examples-advanced/responsive-slider.mdx index 0af11f9..fdde67e 100644 --- a/docs/docs/examples-advanced/responsive-slider.mdx +++ b/docs/docs/examples-advanced/responsive-slider.mdx @@ -10,7 +10,6 @@ The `useResponsiveSize` hook is optimized and only re-renders when the current b ## Example - import { ResponsiveSlider } from './advanced-examples'; @@ -21,7 +20,7 @@ import { ResponsiveSlider } from './advanced-examples'; import { TileSlider, useResponsiveSize } from '@videodock/tile-slider'; const Slider = () => { - const [tilesToShow] = useResponsiveSize([{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }]); + const [tilesToShow] = useResponsiveSize([{ xs: 2, sm: 2, md: 3, lg: 4, xl: 5 }]); return ( + +## Code + +```tsx +import { TileSlider, type TileSliderRef } from '@videodock/tile-slider'; + +const Slider = () => { + const tileSliderRef = useRef(); + const [state, setState] = useState({ index: 0, itemIndex: 0, page: 0 }); + + return ( + <> + setState({ index, itemIndex, page })} + /> +
+

State

+
+ {state.index}{' '} +
+
+ {state.itemIndex}{' '} +
+
+ {state.page} +
+

Controls

+
+ slide(direction: 'left' | 'right') +
+ {' '} + +
+
+ slideToIndex(index: number) +
+ {' '} + {' '} + + +
+
+ slideToPage(page: number) +
+ {' '} + +
+ + ); +}; +``` diff --git a/docs/docs/examples/no-pages.mdx b/docs/docs/examples/no-pages.mdx new file mode 100644 index 0000000..3c4c7ba --- /dev/null +++ b/docs/docs/examples/no-pages.mdx @@ -0,0 +1,61 @@ +--- +sidebar_position: 6 +--- + +# No pages + + +## Example + +import { TileSlider } from '../../../src'; +import { items, renderLeftControl, renderRightControl, renderTile } from '../helpers'; +import '../../../src/style.css'; + + + +## Code + +```tsx +import { TileSlider, type RenderTile, type RenderControl } from '@videodock/tile-slider'; + +type Tile = { + title: string; + image: string; +}; + +// create example data set with 10 tiles +const items: Tile[] = Array.from({ length: 10 }, (_, index) => ({ + title: `Tile ${index}`, + image: `/img/${index}.jpg`, +})); + +const renderTile: RenderTile = ({ item, isVisible }) => ( +
+ {item.title} +
+); + +const renderLeftControl: RenderControl = ({ onClick }) => ( + +); + +const renderRightControl: RenderControl = ({ onClick }) => ( + +); + +const Slider = () => { + return ( + + ); +}; +``` diff --git a/docs/docs/helpers.tsx b/docs/docs/helpers.tsx index f353db0..9172af9 100644 --- a/docs/docs/helpers.tsx +++ b/docs/docs/helpers.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, { useRef, useState } from 'react'; import siteConfig from '@generated/docusaurus.config'; -import { RenderControl, RenderPagination, RenderTile } from '../../src'; +import { RenderControl, RenderPagination, RenderTile, TileSlider, TileSliderRef } from '../../src'; export type Tile = { title: string; @@ -97,7 +97,6 @@ export const makeItems = (length: number): Tile[] => }; }); - export function easeOutElastic(currentTime: number, startValue: number, changeInValue: number, duration: number): number { if (currentTime === 0) { return startValue; @@ -116,6 +115,51 @@ export function easeOutElastic(currentTime: number, startValue: number, changeIn ); } +export const WithRefExample = () => { + const tileSliderRef = useRef(); + const [state, setState] = useState({ index: 0, itemIndex: 0, page: 0 }); + + return ( + <> + setState({ index, itemIndex, page })} + /> +
+

State

+
{state.index}
+
{state.itemIndex}
+
{state.page}
+

Controls

+
+ slide(direction: 'left' | 'right') +
+ {' '} + +
+
+ slideToIndex(index: number) +
+ {' '} + {' '} + + +
+
+ slideToPage(page: number) +
+ {' '} + +
+ + ); +}; + export const items = makeItems(10); export const moreItems = makeItems(50); export const manyItems = makeItems(5000); diff --git a/src/TileSlider.tsx b/src/TileSlider.tsx index 0a944e1..30606da 100644 --- a/src/TileSlider.tsx +++ b/src/TileSlider.tsx @@ -1,8 +1,8 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { useEventCallback } from './hooks/useEventCallback'; -import { AnimationFn, easeOut } from './utils/easing'; -import { getCircularIndex } from './utils/math'; +import { AnimationFn, easeOut, easeOutQuartic } from './utils/easing'; +import { getCircularIndex, interpolate } from './utils/math'; import { clx } from './utils/clx'; import { getVelocity, Position, registerMove, TouchMoves } from './utils/drag'; @@ -13,8 +13,8 @@ export const CYCLE_MODE_ENDLESS = 'endless'; export const PREFERS_REDUCED_MOTION = typeof window !== 'undefined' ? !window.matchMedia('(prefers-reduced-motion)').matches : false; const DRAG_EDGE_SNAP = 50; -const VELOCITY_SPEED = 10; -const SNAPPING_DAMPING = 300; +const SLIDE_SNAPPING_DAMPING = 500; +const DRAG_SNAPPING_DAMPING = 1500; export type Direction = 'left' | 'right'; export type CycleMode = 'stop' | 'restart' | 'endless'; @@ -32,6 +32,7 @@ export type ControlProps = { }; export type PaginationProps = { index: number; + itemIndex: number; total: number; page: number; pages: number; @@ -65,26 +66,35 @@ export type TileSliderProps = { overscan?: number; }; -export const TileSlider = ({ - items, - tilesToShow = 6, - cycleMode = 'endless', - spacing = 12, - showControls = true, - animated = PREFERS_REDUCED_MOTION, - animationFn = easeOut, - pageStep = 'page', - renderTile, - renderLeftControl, - renderRightControl, - renderPagination, - className, - onSwipeStart, - onSwipeEnd, - onSlideStart, - onSlideEnd, - overscan = tilesToShow, -}: TileSliderProps) => { +export type TileSliderRef = { + slide: (direction: Direction) => void; + slideToIndex: (index: number, closest?: boolean) => void; + slideToPage: (page: number) => void; +}; + +const TileSliderComponent = ( + { + items, + tilesToShow = 6, + cycleMode = 'endless', + spacing = 12, + showControls = true, + animated = PREFERS_REDUCED_MOTION, + animationFn = easeOut, + pageStep = 'page', + renderTile, + renderLeftControl, + renderRightControl, + renderPagination, + className, + onSwipeStart, + onSwipeEnd, + onSlideStart, + onSlideEnd, + overscan = tilesToShow, + }: TileSliderProps, + ref: React.ForwardedRef, +) => { const frameRef = useRef() as React.MutableRefObject; const gesturesRef = useRef() as React.MutableRefObject; const responsiveTileWidth = 100 / tilesToShow; @@ -92,7 +102,14 @@ export const TileSlider = ({ const pages = Math.ceil(items.length / tilesToShow); const needControls: boolean = showControls && isMultiPage; - const [state, setState] = useState({ index: 0, page: 0, hasSlideBefore: false }); + const [state, setState] = useState({ + index: 0, + fromIndex: 0, + toIndex: 0, + sliding: false, + page: 0, + hasSlideBefore: false, + }); const showLeftControl: boolean = needControls && !(cycleMode === 'stop' && state.index === 0); const showRightControl: boolean = needControls && !(cycleMode === 'stop' && state.index === items.length - tilesToShow); @@ -115,13 +132,19 @@ export const TileSlider = ({ origin: { x: 0, y: 0 } as Position, moves: [] as TouchMoves, scrolling: false, + cancelled: false, position: 0, velocity: 0, lastRenderedIndex: 0, + animationId: 0, + }); + + const getSliderWidth = useEventCallback(() => { + return parseFloat(getComputedStyle(frameRef.current).width); }); const calculateIndex = useCallback(() => { - const tileWidth = frameRef.current.offsetWidth / tilesToShow; + const tileWidth = getSliderWidth() / tilesToShow; let index = Math.round((sliderDataRef.current.position / tileWidth) * -1); if (!isMultiPage) { @@ -133,7 +156,7 @@ export const TileSlider = ({ } return index; - }, [cycleMode, isMultiPage, items.length, tilesToShow]); + }, [cycleMode, getSliderWidth, isMultiPage, items.length, tilesToShow]); const getSliderPosition = useEventCallback(() => { const transform = getComputedStyle(frameRef.current).transform?.split(', ')[4]; @@ -141,19 +164,21 @@ export const TileSlider = ({ return transform ? parseInt(transform) : 0; }); - const handleSnapping = useEventCallback((index: number, animationFn: AnimationFn) => { - const tileWidth = frameRef.current.offsetWidth / tilesToShow; - const from = sliderDataRef.current.position; + const handleSnapping = useEventCallback((index: number, animationFn: AnimationFn, duration = SLIDE_SNAPPING_DAMPING) => { + const tileWidth = getSliderWidth() / tilesToShow; + const from = getSliderPosition(); + const relativeToPosition = -responsiveTileWidth * index; const to = -(index * tileWidth); const change = to - from; const startTime = Date.now(); const page = Math.floor(getCircularIndex(index, items.length) / tilesToShow); if (!animated) { - setState((state) => ({ index, page, hasSlideBefore: true })); - frameRef.current.style.transform = `translateX(${-responsiveTileWidth * index}%)`; + setState((state) => ({ ...state, index, page, hasSlideBefore: true, sliding: false })); + frameRef.current.style.transform = `translateX(${relativeToPosition}%)`; onSlideEnd?.({ index: index, + itemIndex: getCircularIndex(index, items.length), total: items.length, page, pages, @@ -161,10 +186,13 @@ export const TileSlider = ({ return; } + cancelAnimationFrame(sliderDataRef.current.animationId); + setState((state) => ({ ...state, toIndex: index, fromIndex: state.index, sliding: true })); + const snappingDampening = () => { const currentTime = Date.now() - startTime; - const position = animationFn(currentTime, from, change, SNAPPING_DAMPING); - const index = calculateIndex(); + const position = animationFn(currentTime, from, change, duration); + const currentIndex = calculateIndex(); sliderDataRef.current.position = position; frameRef.current.style.transform = `translateX(${position}px)`; @@ -172,21 +200,28 @@ export const TileSlider = ({ // interrupt if (sliderDataRef.current.scrolling) return; - if (currentTime <= SNAPPING_DAMPING) { - requestAnimationFrame(snappingDampening); + if (currentTime <= duration) { + sliderDataRef.current.animationId = requestAnimationFrame(snappingDampening); } else { - frameRef.current.style.transform = `translateX(${-responsiveTileWidth * index}%)`; + frameRef.current.style.transform = `translateX(${relativeToPosition}%)`; onSlideEnd?.({ - index, + index: currentIndex, + itemIndex: getCircularIndex(currentIndex, items.length), total: items.length, page, pages, }); + setState((state) => ({ + ...state, + index, + page, + sliding: false, + })); } - if (sliderDataRef.current.lastRenderedIndex !== index) { - sliderDataRef.current.lastRenderedIndex = index; - setState({ ...state, index: index, page, hasSlideBefore: true }); + if (sliderDataRef.current.lastRenderedIndex !== currentIndex) { + sliderDataRef.current.lastRenderedIndex = currentIndex; + setState((state) => ({ ...state, index: currentIndex, page })); } }; @@ -199,29 +234,104 @@ export const TileSlider = ({ }, [responsiveTileWidth, tilesToShow]); const handleVelocity = useEventCallback(() => { - const startVelocity = sliderDataRef.current.velocity; - const startPosition = sliderDataRef.current.position; - const velocityTargetPosition = startPosition + startVelocity * VELOCITY_SPEED; + const startTime = Date.now(); + const startVelocity = sliderDataRef.current.velocity * 16; + const tileWidth = getSliderWidth() / tilesToShow; + + // Animation duration based on the speed + const extraDuration = Math.pow(Math.abs(startVelocity), 2) / 5; + const totalDuration = DRAG_SNAPPING_DAMPING + extraDuration; + const blendDuration = Math.round(totalDuration / 2); + + // Snap the slider to the current tile when the velocity is near zero + if (startVelocity > -1 && startVelocity < 1) { + return handleSnapping(calculateIndex(), easeOutQuartic, 500); + } + + // Consider a velocity between -8 and 8 to be a swipe (snap to prev/next index) + // A velocity of 8 is little more than a gentle swipe + if (startVelocity > -8 && startVelocity < 8) { + return handleSnapping(calculateIndex() + (startVelocity > 0 ? -1 : 1), easeOutQuartic, SLIDE_SNAPPING_DAMPING); + } - const tileWidth = frameRef.current.offsetWidth / tilesToShow; - const targetIndex = Math.round((velocityTargetPosition / tileWidth) * -1); + cancelAnimationFrame(sliderDataRef.current.animationId); - handleSnapping(targetIndex, easeOut); + // @todo set `toIndex` based on the velocity? + setState((state) => ({ ...state, fromIndex: state.index, sliding: true })); + + const velocityDampening = () => { + const currentTime = Date.now() - startTime; + const currentIndex = calculateIndex(); //startVelocity > 0 ? 'floor' : 'ceil'); + const page = Math.floor(getCircularIndex(currentIndex, items.length) / tilesToShow); + const blending = currentTime >= totalDuration - blendDuration; + + const velocity = easeOutQuartic(currentTime, startVelocity, -startVelocity, totalDuration); + sliderDataRef.current.position += velocity; + + if (blending) { + const targetPosition = -(currentIndex * tileWidth); + const blendProgress = (currentTime - (totalDuration - blendDuration)) / blendDuration; + + sliderDataRef.current.position = interpolate(sliderDataRef.current.position, targetPosition, blendProgress); + } + + // interrupt + if (sliderDataRef.current.scrolling) return; + + frameRef.current.style.transform = `translateX(${sliderDataRef.current.position}px)`; + + if (currentTime <= totalDuration) { + sliderDataRef.current.animationId = requestAnimationFrame(velocityDampening); + } else { + frameRef.current.style.transform = `translateX(${-responsiveTileWidth * currentIndex}%)`; + onSlideEnd?.({ + index: currentIndex, + itemIndex: getCircularIndex(currentIndex, items.length), + total: items.length, + page, + pages, + }); + setState((state) => ({ + ...state, + index: currentIndex, + page, + sliding: false, + })); + } + + if (sliderDataRef.current.lastRenderedIndex !== currentIndex) { + sliderDataRef.current.lastRenderedIndex = currentIndex; + setState((state) => ({ ...state, index: currentIndex, page })); + } + }; + + sliderDataRef.current.animationId = requestAnimationFrame(velocityDampening); }); const slideToIndex = useCallback( - (index: number) => { - const page = Math.floor(getCircularIndex(state.index, items.length) / tilesToShow); - setState((state) => ({ ...state, index, page })); + (index: number, closest = false) => { + const itemIndex = getCircularIndex(state.index, items.length); + const page = Math.floor(itemIndex / tilesToShow); + + if (closest) { + const toItemIndex = getCircularIndex(index, items.length); + const delta = toItemIndex - itemIndex; + index = state.index + delta; + } + + if (!isMultiPage) return; + + setState((state) => ({ ...state, page })); onSlideStart?.({ index: index, + itemIndex, total: items.length, page, pages, }); handleSnapping(index, stableAnimationFn); }, - [handleSnapping, items.length, onSlideStart, pages, stableAnimationFn, state.index, tilesToShow], + [handleSnapping, isMultiPage, items.length, onSlideStart, pages, stableAnimationFn, state.index, tilesToShow], ); const slideToPage = useCallback( @@ -245,6 +355,18 @@ export const TileSlider = ({ [slideToIndex, state.index, stepCount], ); + useImperativeHandle( + ref, + () => { + return { + slide, + slideToPage, + slideToIndex, + }; + }, + [slide, slideToIndex, slideToPage], + ); + const handleTouchStart = useCallback( (event: TouchEvent): void => { sliderDataRef.current.origin = { @@ -256,8 +378,17 @@ export const TileSlider = ({ sliderDataRef.current.moves = registerMove([], sliderDataRef.current.origin); sliderDataRef.current.position = getSliderPosition(); + sliderDataRef.current.scrolling = true; + sliderDataRef.current.cancelled = false; + onSwipeStart?.(); - onSlideStart?.({ index: state.index, page: state.page, pages, total: items.length }); + onSlideStart?.({ + index: state.index, + itemIndex: getCircularIndex(state.index, items.length), + page: state.page, + pages, + total: items.length, + }); }, [getSliderPosition, items.length, onSlideStart, onSwipeStart, pages, state.index, state.page], ); @@ -276,7 +407,12 @@ export const TileSlider = ({ const movementX: number = Math.abs(newPosition.x - origin.x); const movementY: number = Math.abs(newPosition.y - origin.y); - if ((movementX > movementY && movementX > 10) || scrolling) { + if (movementX < movementY || sliderDataRef.current.cancelled) { + sliderDataRef.current.cancelled = true; + return; + } + + if (movementX > movementY || scrolling) { event.preventDefault(); event.stopPropagation(); @@ -310,7 +446,13 @@ export const TileSlider = ({ // snap to edges when there is nothing to scroll if (!isMultiPage) delta = Math.max(-DRAG_EDGE_SNAP, Math.min(DRAG_EDGE_SNAP, delta)); + sliderDataRef.current.scrolling = false; sliderDataRef.current.velocity = 0; + + if (sliderDataRef.current.cancelled || !isMultiPage) { + return handleVelocity(); + } + sliderDataRef.current.position += delta; // we slide when the movement was mostly horizontal @@ -318,9 +460,9 @@ export const TileSlider = ({ sliderDataRef.current.velocity = velocity; } - sliderDataRef.current.scrolling = false; - handleVelocity(); onSwipeEnd?.(); + + handleVelocity(); }, [handleVelocity, isMultiPage, onSwipeEnd], ); @@ -343,7 +485,9 @@ export const TileSlider = ({ const renderTileContainer = (index: number) => { const itemIndex = getCircularIndex(index, items.length); - const isVisible = index >= state.index && index < state.index + tilesToShow; + const fromIndex = state.sliding ? Math.min(state.index, state.fromIndex, state.toIndex) : state.index; + const toIndex = (state.sliding ? Math.max(state.index, state.fromIndex, state.toIndex) : state.index) + tilesToShow; + const isVisible = index >= fromIndex && index < toIndex; return (
  • ({ )} {renderPagination?.({ index: state.index, + itemIndex: getCircularIndex(state.index, items.length), total: items.length, page: state.page, pages, @@ -396,3 +541,5 @@ export const TileSlider = ({ ); }; + +export const TileSlider = forwardRef(TileSliderComponent); diff --git a/src/index.ts b/src/index.ts index a5ac3fd..0773ec3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { TileSlider, CYCLE_MODE_ENDLESS, CYCLE_MODE_RESTART, CYCLE_MODE_STOP } from './TileSlider'; -export type { RenderTile, RenderControl, RenderPagination, TileSliderProps } from './TileSlider'; +export type { RenderTile, RenderControl, RenderPagination, TileSliderProps, TileSliderRef } from './TileSlider'; export { useResponsiveSize } from './hooks/useResponsiveSize'; export * as easing from './utils/easing'; diff --git a/src/utils/drag.ts b/src/utils/drag.ts index 2012385..79b8c33 100644 --- a/src/utils/drag.ts +++ b/src/utils/drag.ts @@ -2,12 +2,15 @@ export type Position = { x: number; y: number }; export type TouchMoves = { position: Position; ts: number }[]; export const registerMove = (lastMoves: TouchMoves, position: Position) => { - return [{ position, ts: Date.now() }, ...lastMoves].slice(0, 2); + return [{ position, ts: Date.now() }, ...lastMoves].slice(0, 5).filter((move) => move.ts > Date.now() - 250); }; export const getVelocity = (lastMoves: TouchMoves) => { - const distance = lastMoves[0]?.position.x - lastMoves[1]?.position.x; - const time = lastMoves[0]?.ts - lastMoves[1]?.ts; + if (lastMoves.length < 2) return 0; - return distance * (time / 32); + const distance = lastMoves[0].position.x - lastMoves[lastMoves.length - 1].position.x; + const time = lastMoves[0].ts - lastMoves[lastMoves.length - 1].ts; + + // velocity is movement per millisecond + return distance / time; }; diff --git a/src/utils/easing.ts b/src/utils/easing.ts index 348a4cf..5cd1d30 100644 --- a/src/utils/easing.ts +++ b/src/utils/easing.ts @@ -6,6 +6,17 @@ export const easeOut: AnimationFn = (currentTime, startValue, changeInValue, dur return changeInValue * (currentTime * currentTime * currentTime + 1) + startValue; }; +export const easeOutQuartic: AnimationFn = (currentTime, startValue, changeInValue, duration) => { + currentTime /= duration; + currentTime--; + return -changeInValue * (currentTime * currentTime * currentTime * currentTime - 1) + startValue; +}; + +export const easeInOutCubic: AnimationFn = (currentTime, startValue, changeInValue, duration) => { + currentTime /= duration; + return changeInValue * (currentTime < 0.5 ? 4 * currentTime * currentTime * currentTime : 1 - Math.pow(-2 * currentTime + 2, 3) / 2) + startValue; +} + export const easeInOut: AnimationFn = (currentTime, startValue, changeInValue, duration) => { currentTime /= duration / 2; if (currentTime < 1) { diff --git a/src/utils/math.ts b/src/utils/math.ts index 393b15b..4a1002b 100644 --- a/src/utils/math.ts +++ b/src/utils/math.ts @@ -1 +1,5 @@ export const getCircularIndex = (index: number, length: number) => ((index % length) + length) % length; + +export function interpolate(a: number, b: number, t: number): number { + return a * (1 - t) + b * t; +}