diff --git a/packages/block-editor/src/components/block-draggable/index.native.js b/packages/block-editor/src/components/block-draggable/index.native.js index 13533ce17979a2..ec79597397bf92 100644 --- a/packages/block-editor/src/components/block-draggable/index.native.js +++ b/packages/block-editor/src/components/block-draggable/index.native.js @@ -24,6 +24,7 @@ import { useEffect } from '@wordpress/element'; /** * Internal dependencies */ +import useScrollWhenDragging from './use-scroll-when-dragging'; import DraggableChip from './draggable-chip'; import { store as blockEditorStore } from '../../store'; import { useBlockListContext } from '../block-list/block-list-context'; @@ -62,10 +63,19 @@ const BlockDraggableWrapper = ( { children } ) => { const isDragging = useSharedValue( false ); const scrollAnimation = useSharedValue( 0 ); + const [ + startScrolling, + scrollOnDragOver, + stopScrolling, + draggingScrollHandler, + ] = useScrollWhenDragging(); + const scrollHandler = ( event ) => { 'worklet'; const { contentOffset } = event; scroll.offsetY.value = contentOffset.y; + + draggingScrollHandler( event ); }; // Stop dragging blocks if the block draggable is unmounted. @@ -98,9 +108,13 @@ const BlockDraggableWrapper = ( { children } ) => { ( scroll.offsetY.value - blockLayout.y ) - EXTRA_OFFSET_WHEN_CLOSE_TO_TOP_EDGE ); - scrollAnimation.value = withTiming( scrollOffsetTarget, { - duration: SCROLL_ANIMATION_DURATION, - } ); + scrollAnimation.value = withTiming( + scrollOffsetTarget, + { duration: SCROLL_ANIMATION_DURATION }, + () => startScrolling( position.y ) + ); + } else { + runOnUI( startScrolling )( position.y ); } } }; @@ -148,6 +162,9 @@ const BlockDraggableWrapper = ( { children } ) => { chip.x.value = dragPosition.x; chip.y.value = dragPosition.y; + + // Update scrolling velocity + scrollOnDragOver( dragPosition.y ); }; const stopDragging = () => { @@ -156,6 +173,7 @@ const BlockDraggableWrapper = ( { children } ) => { chip.scale.value = withTiming( 0 ); runOnJS( stopDraggingBlocks )(); + stopScrolling(); }; const chipStyles = useAnimatedStyle( () => { diff --git a/packages/block-editor/src/components/block-draggable/use-scroll-when-dragging.native.js b/packages/block-editor/src/components/block-draggable/use-scroll-when-dragging.native.js new file mode 100644 index 00000000000000..d38e15760e76c8 --- /dev/null +++ b/packages/block-editor/src/components/block-draggable/use-scroll-when-dragging.native.js @@ -0,0 +1,124 @@ +/** + * External dependencies + */ +import { Dimensions } from 'react-native'; +import { + useSharedValue, + useAnimatedRef, + scrollTo, + useAnimatedReaction, + withTiming, + withRepeat, + cancelAnimation, + Easing, +} from 'react-native-reanimated'; + +/** + * Internal dependencies + */ +import { useBlockListContext } from '../block-list/block-list-context'; + +const SCROLL_INACTIVE_DISTANCE_PX = 50; +const SCROLL_INTERVAL_MS = 1000; +const VELOCITY_MULTIPLIER = 5000; + +export default function useScrollWhenDragging() { + const { scrollRef } = useBlockListContext(); + const animatedScrollRef = useAnimatedRef(); + animatedScrollRef( scrollRef ); + + const windowHeight = Dimensions.get( 'window' ).height; + + const velocityY = useSharedValue( 0 ); + const offsetY = useSharedValue( 0 ); + const dragStartY = useSharedValue( 0 ); + const animationTimer = useSharedValue( 0 ); + const isAnimationTimerActive = useSharedValue( false ); + const isScrollActive = useSharedValue( false ); + + const scroll = { + offsetY: useSharedValue( 0 ), + maxOffsetY: useSharedValue( 0 ), + }; + const scrollHandler = ( event ) => { + 'worklet'; + const { contentSize, contentOffset, layoutMeasurement } = event; + scroll.offsetY.value = contentOffset.y; + scroll.maxOffsetY.value = contentSize.height - layoutMeasurement.height; + }; + + const stopScrolling = () => { + 'worklet'; + isAnimationTimerActive.value = false; + isScrollActive.value = false; + velocityY.value = 0; + }; + + const startScrolling = ( y ) => { + 'worklet'; + stopScrolling(); + offsetY.value = scroll.offsetY.value; + dragStartY.value = y; + + animationTimer.value = 0; + animationTimer.value = withRepeat( + withTiming( 1, { + duration: SCROLL_INTERVAL_MS, + easing: Easing.linear, + } ), + -1, + true + ); + isAnimationTimerActive.value = true; + }; + + const scrollOnDragOver = ( y ) => { + 'worklet'; + const dragDistance = Math.max( + Math.abs( y - dragStartY.value ) - SCROLL_INACTIVE_DISTANCE_PX, + 0 + ); + const distancePercentage = dragDistance / windowHeight; + + if ( ! isScrollActive.value ) { + isScrollActive.value = dragDistance > 0; + } else if ( y > dragStartY.value ) { + // User is dragging downwards. + velocityY.value = VELOCITY_MULTIPLIER * distancePercentage; + } else if ( y < dragStartY.value ) { + // User is dragging upwards. + velocityY.value = -VELOCITY_MULTIPLIER * distancePercentage; + } else { + velocityY.value = 0; + } + }; + + useAnimatedReaction( + () => animationTimer.value, + ( value, previous ) => { + const delta = Math.abs( value - previous ); + let newOffset = offsetY.value + delta * velocityY.value; + + if ( scroll.maxOffsetY.value !== 0 ) { + newOffset = Math.max( + 0, + Math.min( scroll.maxOffsetY.value, newOffset ) + ); + } else { + // Scroll values are empty until receiving the first scroll event. + // In that case, the max offset is unknown and we can't clamp the + // new offset value. + newOffset = Math.max( 0, newOffset ); + } + offsetY.value = newOffset; + + if ( velocityY.value !== 0 ) { + scrollTo( animatedScrollRef, 0, offsetY.value, false ); + } else if ( ! isAnimationTimerActive.value ) { + cancelAnimation( animationTimer ); + } + } + ); + + return [ startScrolling, scrollOnDragOver, stopScrolling, scrollHandler ]; +}