diff --git a/example/app/src/examples/SortableGrid/features/AutoScrollExample.tsx b/example/app/src/examples/SortableGrid/features/AutoScrollExample.tsx index bd62d17e..a04c76e9 100644 --- a/example/app/src/examples/SortableGrid/features/AutoScrollExample.tsx +++ b/example/app/src/examples/SortableGrid/features/AutoScrollExample.tsx @@ -18,7 +18,7 @@ import { useBottomNavBarHeight } from '@/providers'; import { colors, spacing, style } from '@/theme'; import { getItems } from '@/utils'; -const MANY_ITEMS = getItems(21); +const MANY_ITEMS = getItems(30); const FEW_ITEMS = getItems(6); const LIST_ITEM_SECTIONS = ['List item 1', 'List item 2', 'List item 3']; diff --git a/packages/react-native-sortables/src/components/SortableFlex.tsx b/packages/react-native-sortables/src/components/SortableFlex.tsx index a30d5fea..95dc4317 100644 --- a/packages/react-native-sortables/src/components/SortableFlex.tsx +++ b/packages/react-native-sortables/src/components/SortableFlex.tsx @@ -236,9 +236,7 @@ function SortableFlexComponent({ {...rest} containerStyle={[baseContainerStyle, animatedContainerStyle]} itemStyle={styles.styleOverride} - onLayout={runOnUI((w, h) => { - handleContainerMeasurement(w, h); - })} + onLayout={runOnUI(handleContainerMeasurement)} /> ); } diff --git a/packages/react-native-sortables/src/components/shared/SortableContainer.tsx b/packages/react-native-sortables/src/components/shared/SortableContainer.tsx index f6910420..d66d93f3 100644 --- a/packages/react-native-sortables/src/components/shared/SortableContainer.tsx +++ b/packages/react-native-sortables/src/components/shared/SortableContainer.tsx @@ -115,9 +115,9 @@ export default function SortableContainer({ { - onLayout(layout.width, layout.height); - }}> + onLayout={({ nativeEvent: { layout } }) => + onLayout(layout.width, layout.height) + }> >; const { AutoScrollProvider, useAutoScrollContext } = createProvider( @@ -44,31 +49,28 @@ const { AutoScrollProvider, useAutoScrollContext } = createProvider( scrollableRef, ...rest }) => { - const currentScrollOffset = useScrollViewOffset(scrollableRef); - const dragStartScrollOffset = useMutableValue(null); - const contentBounds = useMutableValue<[Vector, Vector] | null>(null); + const { containerRef } = useCommonValuesContext(); + const scrollOffset = useScrollViewOffset(scrollableRef); - const isVertical = autoScrollDirection === 'vertical'; + const context = useMutableValue(null); + const contentBounds = useMutableValue<[Vector, Vector] | null>(null); const scrollOffsetDiff = useDerivedValue(() => { - if (dragStartScrollOffset.value === null) { - return null; + const startOffset = context.value?.startScrollOffset; + if (startOffset === undefined) { + return 0; } - - return { - [isVertical ? 'y' : 'x']: - currentScrollOffset.value - dragStartScrollOffset.value - }; + return scrollOffset.value - startOffset; }); - const scrollBy = useCallback( - (distance: number, animated: boolean) => { + const isVertical = autoScrollDirection === 'vertical'; + + const scrollToOffset = useCallback( + (offset: number, animated: boolean) => { 'worklet'; - if (distance === 0) { + if (Math.abs(offset - scrollOffset.value) < 1) { return; } - - const offset = currentScrollOffset.value + distance; scrollTo( scrollableRef, isVertical ? 0 : offset, @@ -76,9 +78,37 @@ const { AutoScrollProvider, useAutoScrollContext } = createProvider( animated ); }, - [scrollableRef, isVertical, currentScrollOffset] + [isVertical, scrollOffset, scrollableRef] ); + const scrollBy = useCallback( + (distance: number, animated: boolean) => { + 'worklet'; + if (Math.abs(distance) < 1) { + return; + } + scrollToOffset(scrollOffset.value + distance, animated); + }, + [scrollToOffset, scrollOffset] + ); + + const getSortableOffset = useCallback(() => { + 'worklet'; + const containerMeasurements = measure(containerRef); + const scrollableMeasurements = measure(scrollableRef); + + if (!containerMeasurements || !scrollableMeasurements) { + return null; + } + + const prop = isVertical ? 'pageY' : 'pageX'; + const scrollContainerPosition = + scrollableMeasurements[prop] - scrollOffset.value; + const sortableContainerPosition = containerMeasurements[prop]; + + return sortableContainerPosition - scrollContainerPosition; + }, [containerRef, scrollableRef, scrollOffset, isVertical]); + return { children: ( <> @@ -87,10 +117,12 @@ const { AutoScrollProvider, useAutoScrollContext } = createProvider( )} @@ -98,6 +130,7 @@ const { AutoScrollProvider, useAutoScrollContext } = createProvider( enabled, value: { contentBounds, + isVerticalScroll: isVertical, scrollableRef, scrollBy, scrollOffsetDiff @@ -105,26 +138,16 @@ const { AutoScrollProvider, useAutoScrollContext } = createProvider( }; }); -type StateContextType = { - targetScrollOffset: null | number; - prevContainerOffset: null | number; - lastUpdateTimestamp: null | number; -}; - -const INITIAL_STATE: StateContextType = { - lastUpdateTimestamp: null, - prevContainerOffset: null, - targetScrollOffset: null -}; - type AutoScrollUpdaterProps = Omit< AutoScrollSettings, 'autoScrollDirection' | 'autoScrollEnabled' > & { - currentScrollOffset: SharedValue; - dragStartScrollOffset: SharedValue; + context: SharedValue; + scrollOffset: SharedValue; contentBounds: SharedValue<[Vector, Vector] | null>; isVertical: boolean; + getSortableOffset: () => void; + scrollToOffset: (offset: number, animated: boolean) => void; }; function AutoScrollUpdater({ @@ -135,16 +158,14 @@ function AutoScrollUpdater({ autoScrollMaxOverscroll, autoScrollMaxVelocity, contentBounds, - currentScrollOffset, - dragStartScrollOffset, + context, + getSortableOffset, isVertical, - scrollableRef + scrollableRef, + scrollOffset, + scrollToOffset }: AutoScrollUpdaterProps) { - const { activeAnimationProgress, containerRef, touchPosition } = - useCommonValuesContext(); - - const progress = useMutableValue(0); - const context = useMutableValue(INITIAL_STATE); + const { activeAnimationProgress, touchPosition } = useCommonValuesContext(); const scrollAxis = isVertical ? 'y' : 'x'; const activationOffset = toPair(autoScrollActivationOffset); @@ -161,7 +182,7 @@ function AutoScrollUpdater({ return [start[scrollAxis], end[scrollAxis]]; }); - let debug: ReturnType = {}; + let debug: ReturnType = EMPTY_OBJECT; if (__DEV__) { // eslint-disable-next-line react-hooks/rules-of-hooks debug = useDebugHelpers( @@ -172,134 +193,60 @@ function AutoScrollUpdater({ ); } - const calculateRawProgress = isVertical - ? calculateRawProgressVertical - : calculateRawProgressHorizontal; - - useAnimatedReaction( - () => { - const ctx = context.value; - let position = touchPosition.value?.[scrollAxis] ?? null; - if (position !== null && ctx.targetScrollOffset !== null) { - // Sometimes the scroll distance is so small that the scrollTo takes - // no effect. To handle this case, we have to update the position - // of the view used to determine the progress, even if the actual - // position of the view is not changed (because of too small scroll distance). - position += ctx.targetScrollOffset - currentScrollOffset.value; - } - return position; - }, - position => { - if (position === null) { - context.value.targetScrollOffset = null; - debug?.hideDebugViews?.(); - return; - } - - const contentContainerMeasurements = measure(containerRef); - const scrollContainerMeasurements = measure(scrollableRef); - if (!contentContainerMeasurements || !scrollContainerMeasurements) { - debug?.hideDebugViews?.(); - return; - } - - progress.value = calculateRawProgress( - position, - contentContainerMeasurements, - scrollContainerMeasurements, - activationOffset, - maxOverscroll, - autoScrollExtrapolation - ); - - if (progress.value === 0) { - context.value.targetScrollOffset = null; - } - - debug?.updateDebugRects?.( - contentContainerMeasurements, - scrollContainerMeasurements - ); - }, - [debug] - ); - const scrollBy = useCallback( (distance: number, animated: boolean) => { 'worklet'; + const ctx = context.value; const bounds = contentAxisBounds.value; - const containerMeasurements = measure(containerRef); const scrollableMeasurements = measure(scrollableRef); - if (!bounds || !scrollableMeasurements || !containerMeasurements) { + if (!ctx || !bounds || !scrollableMeasurements) { return; } - const ctx = context.value; - const pendingDistance = - ctx.targetScrollOffset !== null - ? ctx.targetScrollOffset - currentScrollOffset.value - : 0; - - const containerOffset = isVertical - ? scrollableMeasurements.pageY - containerMeasurements.pageY - : scrollableMeasurements.pageX - containerMeasurements.pageX; - const scrollableCrossSize = isVertical - ? scrollableMeasurements.height - : scrollableMeasurements.width; - - if ( - Math.abs(pendingDistance) > 1 && - containerOffset === ctx.prevContainerOffset - ) { - // Return if measurements haven't been updated yet (we scroll based on the - // relative position of the container in the ScrollView so we have to ensure - // that the last update is already applied) + const scrollableSize = + scrollableMeasurements[isVertical ? 'height' : 'width']; + + let newScrollOffset = 0; + + if (distance > 0) { + // scroll down + newScrollOffset = Math.min( + ctx.targetScrollOffset + distance, + ctx.sortableOffset - scrollableSize + bounds[1] + maxOverscroll[1] + ); + } else if (distance < 0) { + // scroll up + newScrollOffset = Math.max( + ctx.targetScrollOffset + distance, + ctx.sortableOffset + bounds[0] - maxOverscroll[0] + ); + } else return; + + if (Math.abs(newScrollOffset - ctx.targetScrollOffset) < 1) { return; } - const clampedDistance = clampDistance( - distance + pendingDistance, - containerOffset, - scrollableCrossSize, - bounds, - maxOverscroll - ); - - const targetOffset = currentScrollOffset.value + clampedDistance; - - ctx.targetScrollOffset = targetOffset; - - if (Math.abs(clampedDistance) < 1) { - return; - } - - ctx.prevContainerOffset = containerOffset; - scrollTo( - scrollableRef, - isVertical ? 0 : targetOffset, - isVertical ? targetOffset : 0, - animated - ); + ctx.targetScrollOffset = newScrollOffset; + scrollToOffset(newScrollOffset, animated); }, [ context, - currentScrollOffset, - isVertical, - scrollableRef, - containerRef, + scrollToOffset, contentAxisBounds, - maxOverscroll + maxOverscroll, + isVertical, + scrollableRef ] ); const frameCallbackFunction = useCallback( ({ timestamp }: FrameInfo) => { 'worklet'; - if (progress.value === 0) { + const ctx = context.value; + if (!ctx?.progress) { return; } - const ctx = context.value; ctx.lastUpdateTimestamp ??= timestamp; const elapsedTime = timestamp - ctx.lastUpdateTimestamp; if (elapsedTime < autoScrollInterval) { @@ -315,13 +262,14 @@ function AutoScrollUpdater({ ctx.lastUpdateTimestamp = timestamp; const velocity = interpolate( - progress.value, + ctx.progress, [-1, 0, 1], [-maxStartVelocity, 0, maxEndVelocity] ); const distance = velocity * (cappedElapsedTime / 1000); + console.log('frameCallbackFunction', distance, ctx.progress); scrollBy(distance, animateScrollTo); }, [ @@ -329,13 +277,12 @@ function AutoScrollUpdater({ scrollBy, maxStartVelocity, maxEndVelocity, - progress, autoScrollInterval, animateScrollTo ] ); - const frameCallback = useFrameCallback(frameCallbackFunction, false); + const frameCallback = useFrameCallback(frameCallbackFunction); const toggleFrameCallback = useCallback( (enabled: boolean) => { @@ -344,22 +291,75 @@ function AutoScrollUpdater({ [frameCallback] ); + const enableAutoScroll = useCallback(() => { + 'worklet'; + if (context.value) { + return; + } + + context.value = { + progress: 0, + sortableOffset: getSortableOffset() ?? 0, + startScrollOffset: scrollOffset.value, + targetScrollOffset: scrollOffset.value + }; + + runOnJS(toggleFrameCallback)(true); + }, [context, scrollOffset, toggleFrameCallback, getSortableOffset]); + + const disableAutoScroll = useCallback(() => { + 'worklet'; + if (!context.value) { + return; + } + context.value = null; + debug?.hideDebugViews?.(); + runOnJS(toggleFrameCallback)(false); + }, [toggleFrameCallback, context, debug]); + useAnimatedReaction( () => isActive.value, - active => { - if (active) { - dragStartScrollOffset.value = currentScrollOffset.value; - const ctx = context.value; - ctx.lastUpdateTimestamp = null; - ctx.targetScrollOffset = null; - ctx.prevContainerOffset = null; - runOnJS(toggleFrameCallback)(true); - } else { - dragStartScrollOffset.value = null; - runOnJS(toggleFrameCallback)(false); + active => (active ? enableAutoScroll() : disableAutoScroll()), + [enableAutoScroll, disableAutoScroll] + ); + + useAnimatedReaction( + () => ({ + bounds: contentAxisBounds.value, + ctx: context.value, + position: touchPosition.value?.[scrollAxis] + }), + ({ bounds, ctx, position }) => { + if (position === undefined || !bounds || !ctx) { + disableAutoScroll(); + return; + } + + const scrollableMeasurements = measure(scrollableRef); + if (!scrollableMeasurements) { + disableAutoScroll(); + return; + } + + const scrollableSize = + scrollableMeasurements[isVertical ? 'height' : 'width']; + const containerPos = ctx.sortableOffset - scrollOffset.value; + + ctx.progress = calculateRawProgress( + position, + containerPos, + scrollableSize, + activationOffset, + bounds, + maxOverscroll, + autoScrollExtrapolation + ); + + if (debug) { + debug?.updateDebugRects?.(containerPos, scrollableSize); } }, - [currentScrollOffset] + [debug] ); return null; diff --git a/packages/react-native-sortables/src/providers/shared/AutoScrollProvider/useDebugHelpers.ts b/packages/react-native-sortables/src/providers/shared/AutoScrollProvider/useDebugHelpers.ts index b0ba66b1..477e939a 100644 --- a/packages/react-native-sortables/src/providers/shared/AutoScrollProvider/useDebugHelpers.ts +++ b/packages/react-native-sortables/src/providers/shared/AutoScrollProvider/useDebugHelpers.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import type { MeasuredDimensions, SharedValue } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; import { useDebugContext } from '../../../debug'; @@ -15,7 +15,7 @@ const OVERSCROLL_COLORS = { export default function useDebugHelpers( isVertical: boolean, - [startOffset, endOffset]: [number, number], + [startActivationOffset, endActivationOffset]: [number, number], contentBounds: SharedValue<[number, number] | null>, [maxStartOverscroll, maxEndOverscroll]: [number, number] ) { @@ -37,39 +37,28 @@ export default function useDebugHelpers( }, [debugRects]); const updateDebugRects = useCallback( - ( - contentContainerMeasurements: MeasuredDimensions, - scrollContainerMeasurements: MeasuredDimensions - ) => { + (containerPos: number, scrollableSize: number) => { 'worklet'; - const { pageX: cX, pageY: cY } = contentContainerMeasurements; - const { - height: sH, - pageX: sX, - pageY: sY, - width: sW - } = scrollContainerMeasurements; - const startTriggerProps = isVertical ? { - height: startOffset, - y: sY - cY + height: startActivationOffset, + y: -containerPos } : { - width: startOffset, - x: sX - cX + width: startActivationOffset, + x: -containerPos }; const endTriggerProps = isVertical ? { - height: endOffset, + height: endActivationOffset, positionOrigin: 'bottom' as const, - y: sY - cY + sH + y: -containerPos + scrollableSize } : { positionOrigin: 'right' as const, - width: endOffset, - x: sX - cX + sW + width: endActivationOffset, + x: -containerPos + scrollableSize }; debugRects?.start.set({ ...TRIGGER_COLORS, ...startTriggerProps }); @@ -114,11 +103,11 @@ export default function useDebugHelpers( [ debugRects, isVertical, - startOffset, - endOffset, contentBounds, maxStartOverscroll, - maxEndOverscroll + maxEndOverscroll, + startActivationOffset, + endActivationOffset ] ); diff --git a/packages/react-native-sortables/src/providers/shared/AutoScrollProvider/utils.ts b/packages/react-native-sortables/src/providers/shared/AutoScrollProvider/utils.ts index c11865d7..d4614561 100644 --- a/packages/react-native-sortables/src/providers/shared/AutoScrollProvider/utils.ts +++ b/packages/react-native-sortables/src/providers/shared/AutoScrollProvider/utils.ts @@ -1,96 +1,42 @@ 'worklet'; -import type { - ExtrapolationType, - MeasuredDimensions -} from 'react-native-reanimated'; +import type { ExtrapolationType } from 'react-native-reanimated'; import { Extrapolation, interpolate } from 'react-native-reanimated'; -type CalculateRawProgressFunction = ( +export const calculateRawProgress = ( position: number, - contentContainerMeasurements: MeasuredDimensions, - scrollContainerMeasurements: MeasuredDimensions, + containerPos: number, + scrollableSize: number, activationOffset: [number, number], + contentBounds: [number, number], maxOverscroll: [number, number], extrapolation: ExtrapolationType -) => number; - -const calculateRawProgress = ( - position: number, - contentPos: number, - contentSize: number, - scrollablePos: number, - scrollableSize: number, - [startOffset, endOffset]: [number, number], - [maxStartOverscroll, maxEndOverscroll]: [number, number], - extrapolation: ExtrapolationType ) => { - const startBound = scrollablePos - contentPos; - const startThreshold = startBound + startOffset; - const endBound = startBound + scrollableSize; - const endThreshold = endBound - endOffset; - - const startBoundProgress = -interpolate( - startBound, - [-maxStartOverscroll, startOffset], - [0, 1], + const startDistance = containerPos + contentBounds[0]; + const startBoundProgress = interpolate( + startDistance, + [-activationOffset[0], maxOverscroll[0]], + [1, 0], Extrapolation.CLAMP ); + const contentSize = contentBounds[1] - contentBounds[0]; + const endDistance = scrollableSize - contentSize - startDistance; const endBoundProgress = interpolate( - endBound, - [contentSize - endOffset, contentSize + maxEndOverscroll], + endDistance, + [-activationOffset[1], maxOverscroll[1]], [1, 0], Extrapolation.CLAMP ); + const startBound = -containerPos; + const startThreshold = startBound + activationOffset[0]; + const endBound = startBound + scrollableSize; + const endThreshold = endBound - activationOffset[1]; + return interpolate( position, [startBound, startThreshold, endThreshold, endBound], - [startBoundProgress, 0, 0, endBoundProgress], + [-startBoundProgress, 0, 0, endBoundProgress], extrapolation ); }; - -export const calculateRawProgressVertical: CalculateRawProgressFunction = ( - position, - { height: cH, pageY: cY }, - { height: sH, pageY: sY }, - ...rest -) => calculateRawProgress(position, cY, cH, sY, sH, ...rest); - -export const calculateRawProgressHorizontal: CalculateRawProgressFunction = ( - position, - { pageX: cX, width: cW }, - { pageX: sX, width: sW }, - ...rest -) => calculateRawProgress(position, cX, cW, sX, sW, ...rest); - -export const clampDistance = ( - distance: number, - containerOffset: number, - scrollableSize: number, - [startOffset, endOffset]: [number, number], - [maxStartOverscroll, maxEndOverscroll]: [number, number] -) => { - if (distance < 0) { - // Scrolling up - return Math.min( - 0, - Math.max(containerOffset + distance, startOffset - maxStartOverscroll) - - containerOffset - ); - } - - if (distance > 0) { - // Scrolling down - return Math.max( - 0, - Math.min( - containerOffset + distance, - endOffset - scrollableSize + maxEndOverscroll - ) - containerOffset - ); - } - - return 0; -}; diff --git a/packages/react-native-sortables/src/providers/shared/DragProvider.ts b/packages/react-native-sortables/src/providers/shared/DragProvider.ts index 2bfef8b2..9cf24b23 100644 --- a/packages/react-native-sortables/src/providers/shared/DragProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/DragProvider.ts @@ -108,7 +108,7 @@ const { DragProvider, useDragContext } = createProvider('Drag')< usesAbsoluteLayout } = useCommonValuesContext(); const { updateLayer } = useLayerContext() ?? {}; - const { scrollOffsetDiff } = useAutoScrollContext() ?? {}; + const { isVerticalScroll, scrollOffsetDiff } = useAutoScrollContext() ?? {}; const { activeHandleMeasurements, activeHandleOffset, @@ -141,10 +141,10 @@ const { DragProvider, useDragContext } = createProvider('Drag')< enableSnap: enableActiveItemSnap.value, itemTouchOffset: context.value.dragStartItemTouchOffset, key: activeItemKey.value, - offsetDiff: scrollOffsetDiff?.value, offsetX: snapOffsetX.value, offsetY: snapOffsetY.value, progress: activeAnimationProgress.value, + scrollDiff: scrollOffsetDiff?.value, snapItemDimensions: activeHandleMeasurements?.value ?? activeItemDimensions.value, snapItemOffset: activeHandleOffset?.value, @@ -159,10 +159,10 @@ const { DragProvider, useDragContext } = createProvider('Drag')< enableSnap, itemTouchOffset, key, - offsetDiff, offsetX, offsetY, progress, + scrollDiff, snapItemDimensions, snapItemOffset, startTouch, @@ -188,16 +188,18 @@ const { DragProvider, useDragContext } = createProvider('Drag')< // Touch position const newTouchPosition = { - x: - startTouchPosition.x + - (touch.absoluteX - startTouch.absoluteX) + - (offsetDiff?.x ?? 0), - y: - startTouchPosition.y + - (touch.absoluteY - startTouch.absoluteY) + - (offsetDiff?.y ?? 0) + x: startTouchPosition.x + (touch.absoluteX - startTouch.absoluteX), + y: startTouchPosition.y + (touch.absoluteY - startTouch.absoluteY) }; + if (scrollDiff) { + if (isVerticalScroll) { + newTouchPosition.y += scrollDiff; + } else { + newTouchPosition.x += scrollDiff; + } + } + if ( !touchPosition.value || areVectorsDifferent(newTouchPosition, touchPosition.value) diff --git a/packages/react-native-sortables/src/types/providers/shared.ts b/packages/react-native-sortables/src/types/providers/shared.ts index 49aef3fa..0ecf4cd1 100644 --- a/packages/react-native-sortables/src/types/providers/shared.ts +++ b/packages/react-native-sortables/src/types/providers/shared.ts @@ -113,7 +113,8 @@ export type MeasurementsContextType = { export type AutoScrollContextType = { scrollableRef: AnimatedRef; - scrollOffsetDiff: SharedValue>; + scrollOffsetDiff: SharedValue; + isVerticalScroll: boolean; contentBounds: SharedValue<[Vector, Vector] | null>; scrollBy: (distance: number, animated: boolean) => void; };