From 4804304b81e1fc3ea7aa5611cf004228a8f157fe Mon Sep 17 00:00:00 2001 From: Jakz Date: Mon, 22 Apr 2024 10:56:34 +0800 Subject: [PATCH] sync with scroll --- .../GalleryEditor/GalleryEditorRender.tsx | 55 +++++++++++++----- .../GalleryEditor/GalleryEditorSection.tsx | 15 ++++- .../components/GalleryEditor/SortableRow.tsx | 56 +++++++++++++++++-- .../GalleryEditor/SortableRowList.tsx | 18 +++++- 4 files changed, 123 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/components/GalleryEditor/GalleryEditorRender.tsx b/apps/mobile/src/components/GalleryEditor/GalleryEditorRender.tsx index 33ce66928..fdfa60bb5 100644 --- a/apps/mobile/src/components/GalleryEditor/GalleryEditorRender.tsx +++ b/apps/mobile/src/components/GalleryEditor/GalleryEditorRender.tsx @@ -1,7 +1,8 @@ import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { FlashList, ListRenderItem } from '@shopify/flash-list'; import { useCallback, useEffect, useMemo } from 'react'; -import { View } from 'react-native'; +import { NativeScrollEvent, NativeSyntheticEvent, View } from 'react-native'; +import { useAnimatedRef, useSharedValue } from 'react-native-reanimated'; import { graphql, useFragment } from 'react-relay'; import { useGalleryEditorActions } from '~/contexts/GalleryEditor/GalleryEditorContext'; @@ -17,7 +18,7 @@ import { GalleryEditorHeader } from './GalleryEditorHeader'; import { GalleryEditorNavbar } from './GalleryEditorNavbar'; import { GalleryEditorSection } from './GalleryEditorSection'; -type ListItemType = +export type ListItemType = | { kind: 'navigation'; title: string } | { kind: 'header'; galleryRef: GalleryEditorHeaderFragment$key } | { kind: 'section'; section: StagedSection; queryRef: GalleryEditorSectionFragment$key }; @@ -86,17 +87,37 @@ export function GalleryEditorRender({ galleryRef, queryRef }: Props) { return items; }, [gallery, sections, query]); - const renderItem = useCallback>(({ item }) => { - if (item.kind === 'header') { - return ; - } else if (item.kind === 'navigation') { - return ; - } else if (item.kind === 'section') { - return ; - } else { - return null; - } - }, []); + const scrollContentOffsetY = useSharedValue(0); + const ref = useAnimatedRef>(); + + const renderItem = useCallback>( + ({ item }) => { + if (item.kind === 'header') { + return ; + } else if (item.kind === 'navigation') { + return ; + } else if (item.kind === 'section') { + return ( + + ); + } else { + return null; + } + }, + [ref, scrollContentOffsetY] + ); + + const handleScroll = useCallback( + (e: NativeSyntheticEvent) => { + scrollContentOffsetY.value = e.nativeEvent.contentOffset.y; + }, + [scrollContentOffsetY] + ); return ( - + ); } diff --git a/apps/mobile/src/components/GalleryEditor/GalleryEditorSection.tsx b/apps/mobile/src/components/GalleryEditor/GalleryEditorSection.tsx index 775f6d986..6f56c93d5 100644 --- a/apps/mobile/src/components/GalleryEditor/GalleryEditorSection.tsx +++ b/apps/mobile/src/components/GalleryEditor/GalleryEditorSection.tsx @@ -1,6 +1,8 @@ +import { FlashList } from '@shopify/flash-list'; import clsx from 'clsx'; import { useCallback, useMemo } from 'react'; import { View } from 'react-native'; +import { AnimatedRef, SharedValue } from 'react-native-reanimated'; import { graphql, useFragment } from 'react-relay'; import { DragIcon } from 'src/icons/DragIcon'; @@ -11,14 +13,23 @@ import { GalleryEditorSectionFragment$key } from '~/generated/GalleryEditorSecti import { GalleryTouchableOpacity } from '../GalleryTouchableOpacity'; import ProcessedText from '../ProcessedText/ProcessedText'; import { BaseM } from '../Text'; +import { ListItemType } from './GalleryEditorRender'; import { SortableRowList } from './SortableRowList'; type Props = { section: StagedSection; queryRef: GalleryEditorSectionFragment$key; + + scrollContentOffsetY: SharedValue; + scrollViewRef: AnimatedRef>; }; -export function GalleryEditorSection({ section, queryRef }: Props) { +export function GalleryEditorSection({ + section, + queryRef, + scrollContentOffsetY, + scrollViewRef, +}: Props) { const query = useFragment( graphql` fragment GalleryEditorSectionFragment on Query { @@ -76,6 +87,8 @@ export function GalleryEditorSection({ section, queryRef }: Props) { sectionId={section.dbid} queryRef={query} onDragEnd={handleDragEnd} + scrollContentOffsetY={scrollContentOffsetY} + scrollViewRef={scrollViewRef} /> diff --git a/apps/mobile/src/components/GalleryEditor/SortableRow.tsx b/apps/mobile/src/components/GalleryEditor/SortableRow.tsx index f60c7d7c0..dd6f21454 100644 --- a/apps/mobile/src/components/GalleryEditor/SortableRow.tsx +++ b/apps/mobile/src/components/GalleryEditor/SortableRow.tsx @@ -1,8 +1,12 @@ +import { FlashList } from '@shopify/flash-list'; import { useCallback } from 'react'; +import { useWindowDimensions } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { trigger } from 'react-native-haptic-feedback'; import Animated, { + AnimatedRef, runOnJS, + scrollTo, SharedValue, useAnimatedReaction, useAnimatedStyle, @@ -11,7 +15,9 @@ import Animated, { withSpring, withTiming, } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ListItemType } from './GalleryEditorRender'; import { ItemHeights, Positions } from './SortableRowList'; type Props = { @@ -20,6 +26,8 @@ type Props = { positions: SharedValue; animatedIndex: SharedValue; itemHeights: SharedValue; + scrollContentOffsetY: SharedValue; + scrollViewRef: AnimatedRef>; onDragEnd: (data: string[]) => void; }; @@ -30,6 +38,8 @@ export function SortableRow({ positions, animatedIndex, itemHeights, + scrollContentOffsetY, + scrollViewRef, onDragEnd, }: Props) { const itemHeight = itemHeights.value[index] ?? 0; @@ -93,6 +103,36 @@ export function SortableRow({ [positions, itemHeights] ); + // Get safe area insets and window dimensions + const inset = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); + const containerHeight = windowHeight - inset.top; + + // Callback to handle edge cases while scrolling + // (when the user drags the item to the top or bottom of the list) + // Since we need to update the scroll position of the scroll view (scrollTo) + const scrollLogic = useCallback( + ({ absoluteY }: { absoluteY: number }) => { + 'worklet'; + const lowerBound = 1.5 * itemHeight; + const upperBound = scrollContentOffsetY.value + containerHeight; + + // scroll speed is proportional to the item height (the bigger the item, the faster it scrolls) + const scrollSpeed = itemHeight * 0.1; + + if (absoluteY <= lowerBound) { + // while scrolling to the top of the list + const nextPosition = scrollContentOffsetY.value - scrollSpeed; + scrollTo(scrollViewRef, 0, Math.max(nextPosition, 0), false); + } else if (absoluteY + scrollContentOffsetY.value >= upperBound) { + // while scrolling to the bottom of the list + const nextPosition = scrollContentOffsetY.value + scrollSpeed; + scrollTo(scrollViewRef, 0, Math.max(nextPosition, 0), false); + } + }, + [containerHeight, itemHeight, scrollContentOffsetY.value, scrollViewRef] + ); + // Need to keep track of the previous positions to check if the positions have changed // This is needed to trigger the onDragEnd callback const prevPositions = useSharedValue({}); @@ -104,17 +144,25 @@ export function SortableRow({ prevPositions.value = Object.assign({}, positions.value); animatedIndex.value = index; - - contextY.value = positions.value[index] ?? 0; + // Keep the reference of the initialContentOffset + // But that's extremely important to handle the edge cases while scrolling + // Notice: + // 1. In the context we subtract the scrollContentOffsetY.value + // 2. In the onUpdate we add the scrollContentOffsetY.value + // In the common case the contribution of the scrollContentOffsetY.value will be 0 + // But in the edge cases the scrollContentOffsetY.value will be updated during the onUpdate + contextY.value = (positions.value[index] ?? 0) - scrollContentOffsetY.value; translateX.value = event.translationX; runOnJS(trigger)('impactLight'); }) .onUpdate((event) => { - const { translationY } = event; + const { absoluteY, translationY } = event; + + const translateY = contextY.value + translationY + scrollContentOffsetY.value; - const translateY = contextY.value + translationY; + scrollLogic({ absoluteY }); for (let i = 0; i < Object.keys(positions.value).length; i++) { // Check if the translateY is in range of another item diff --git a/apps/mobile/src/components/GalleryEditor/SortableRowList.tsx b/apps/mobile/src/components/GalleryEditor/SortableRowList.tsx index bb90a1086..cbb4f0476 100644 --- a/apps/mobile/src/components/GalleryEditor/SortableRowList.tsx +++ b/apps/mobile/src/components/GalleryEditor/SortableRowList.tsx @@ -1,11 +1,13 @@ +import { FlashList } from '@shopify/flash-list'; import { useEffect, useMemo } from 'react'; import { useWindowDimensions, View } from 'react-native'; -import { useSharedValue } from 'react-native-reanimated'; +import { AnimatedRef, SharedValue, useSharedValue } from 'react-native-reanimated'; import { graphql, useFragment } from 'react-relay'; import { StagedRowList } from '~/contexts/GalleryEditor/types'; import { SortableRowListFragment$key } from '~/generated/SortableRowListFragment.graphql'; +import { ListItemType } from './GalleryEditorRender'; import { GalleryEditorRow } from './GalleryEditorRow'; import { SortableRow } from './SortableRow'; import { calculateItemHeights, calculateOffsetsRow, calculatePositions } from './utils'; @@ -17,6 +19,9 @@ type Props = { queryRef: SortableRowListFragment$key; onDragEnd: (data: string[]) => void; + + scrollContentOffsetY: SharedValue; + scrollViewRef: AnimatedRef>; }; type Index = number; @@ -26,7 +31,14 @@ type HeightValue = number; export type Positions = Record; export type ItemHeights = Record; -export function SortableRowList({ rows, sectionId, queryRef, onDragEnd }: Props) { +export function SortableRowList({ + rows, + sectionId, + queryRef, + onDragEnd, + scrollContentOffsetY, + scrollViewRef, +}: Props) { const query = useFragment( graphql` fragment SortableRowListFragment on Query { @@ -80,6 +92,8 @@ export function SortableRowList({ rows, sectionId, queryRef, onDragEnd }: Props) positions={positions} animatedIndex={animatedIndex} itemHeights={itemHeights} + scrollContentOffsetY={scrollContentOffsetY} + scrollViewRef={scrollViewRef} onDragEnd={onDragEnd} >