Skip to content

Commit

Permalink
Make the token draggable
Browse files Browse the repository at this point in the history
  • Loading branch information
jakzaizzat committed Apr 25, 2024
1 parent e23bb7d commit d2121e7
Show file tree
Hide file tree
Showing 6 changed files with 343 additions and 31 deletions.
61 changes: 31 additions & 30 deletions apps/mobile/src/components/GalleryEditor/GalleryEditorRow.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { FlashList } from '@shopify/flash-list';
import clsx from 'clsx';
import React, { useCallback } from 'react';
import { GestureResponderEvent, View, ViewProps } from 'react-native';
import Animated from 'react-native-reanimated';
import Animated, { AnimatedRef, SharedValue } from 'react-native-reanimated';
import { graphql, useFragment } from 'react-relay';

import { useGalleryEditorActions } from '~/contexts/GalleryEditor/GalleryEditorContext';
Expand All @@ -10,17 +11,28 @@ import { GalleryEditorRowFragment$key } from '~/generated/GalleryEditorRowFragme

import { GalleryTouchableOpacity } from '../GalleryTouchableOpacity';
import { GalleryEditorActiveActions } from './GalleryEditorActiveActions';
import { GalleryEditorTokenPreview } from './GalleryEditorTokenPreview';
import { ListItemType } from './GalleryEditorRenderer';
import { SortableTokenGrid } from './SortableTokenGrid/SortableTokenGrid';
import { useWidthPerToken } from './useWidthPerToken';

type Props = {
sectionId: string;
row: StagedRow;
style?: ViewProps['style'];
queryRef: GalleryEditorRowFragment$key;

scrollContentOffsetY: SharedValue<number>;
scrollViewRef: AnimatedRef<FlashList<ListItemType>>;
};

export function GalleryEditorRow({ sectionId, row, style, queryRef }: Props) {
export function GalleryEditorRow({
sectionId,
row,
style,
queryRef,
scrollContentOffsetY,
scrollViewRef,
}: Props) {
const query = useFragment(
graphql`
fragment GalleryEditorRowFragment on Query {
Expand All @@ -30,7 +42,7 @@ export function GalleryEditorRow({ sectionId, row, style, queryRef }: Props) {
queryRef
);

const { activateRow, activeRowId } = useGalleryEditorActions();
const { activateRow, activeRowId, moveItem } = useGalleryEditorActions();

const widthPerToken = useWidthPerToken(row.columns);

Expand All @@ -42,6 +54,13 @@ export function GalleryEditorRow({ sectionId, row, style, queryRef }: Props) {
[activateRow, sectionId, row.id]
);

const handleDragEnd = useCallback(
(newPositions: string[]) => {
moveItem(row.id, newPositions);
},
[moveItem, row.id]
);

return (
<Animated.View className={clsx('border border-transparent gap-4')}>
<GalleryTouchableOpacity
Expand All @@ -56,36 +75,18 @@ export function GalleryEditorRow({ sectionId, row, style, queryRef }: Props) {
>
<View>
<View className="flex-row flex-wrap gap-2">
{row.items.map((item) => {
if (item.kind === 'whitespace') {
return <WhiteSpace key={item.id} size={widthPerToken - 8} />;
} else {
return (
<View
key={item.id}
className="aspect-square"
style={{
width: widthPerToken - 8,
}}
>
<GalleryEditorTokenPreview tokenRef={item.tokenRef} />
</View>
);
}
})}
<SortableTokenGrid
columns={row.columns}
items={row.items}
size={widthPerToken}
scrollContentOffsetY={scrollContentOffsetY}
scrollViewRef={scrollViewRef}
onDragEnd={handleDragEnd}
/>
</View>
{activeRowId === row.id && <GalleryEditorActiveActions row={row} queryRef={query} />}
</View>
</GalleryTouchableOpacity>
</Animated.View>
);
}

type WhiteSpaceProps = {
size: number;
style?: ViewProps['style'];
};

function WhiteSpace({ size, style }: WhiteSpaceProps) {
return <View style={[{ width: size, height: size }, style]} />;
}
8 changes: 7 additions & 1 deletion apps/mobile/src/components/GalleryEditor/SortableRowList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,13 @@ export function SortableRowList({
scrollViewRef={scrollViewRef}
onDragEnd={onDragEnd}
>
<GalleryEditorRow sectionId={sectionId} row={row} queryRef={query} />
<GalleryEditorRow
sectionId={sectionId}
row={row}
queryRef={query}
scrollContentOffsetY={scrollContentOffsetY}
scrollViewRef={scrollViewRef}
/>
</SortableRow>
);
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
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,
Easing,
runOnJS,
scrollTo,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { ListItemType } from '../GalleryEditorRenderer';
import { getOrder, getPosition, Positions } from './utils';

type Props = {
children: React.ReactNode;
id: string;
positions: SharedValue<Positions>;
size: number;
columns: number;

scrollContentOffsetY: SharedValue<number>;
scrollViewRef: AnimatedRef<FlashList<ListItemType>>;

onDragEnd: (data: string[]) => void;
};

export function SortableToken({
children,
columns,
positions,
size,
id,

scrollContentOffsetY,
scrollViewRef,
onDragEnd,
}: Props) {
const position = getPosition(positions.value[id] || 0, columns, size);

// Shared values for gesture handling
const contextX = useSharedValue(0);
const contextY = useSharedValue(0);

const translateX = useSharedValue(position.x);
const translateY = useSharedValue(position.y);

const isGestureActive = useSharedValue(false);

// Get safe area insets and window dimensions
const inset = useSafeAreaInsets();
const { height: windowHeight } = useWindowDimensions();
const containerHeight = windowHeight - inset.top;

useAnimatedReaction(
() => positions.value[id],
(newOrder) => {
// if (!newOrder) return;
const newPosition = getPosition(newOrder || 0, columns, size);
translateX.value = withTiming(newPosition.x, {
duration: 350,
easing: Easing.inOut(Easing.ease),
});
translateY.value = withTiming(newPosition.y, {
duration: 350,
easing: Easing.inOut(Easing.ease),
});
}
);

// 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 * size;
const upperBound = scrollContentOffsetY.value + containerHeight;
const scrollSpeed = size * 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, scrollContentOffsetY, scrollViewRef, size]
);

const panGesture = Gesture.Pan()
.activateAfterLongPress(300)
.onStart(() => {
isGestureActive.value = true;
contextX.value = position.x;
contextY.value = position.y - scrollContentOffsetY.value;

runOnJS(trigger)('impactLight');
})
.onUpdate((event) => {
const { translationX, translationY, absoluteY } = event;

translateX.value = contextX.value + translationX;
translateY.value = contextY.value + translationY + scrollContentOffsetY.value;

const oldOrder = positions.value[id];
const newOrder = getOrder(translateX.value, translateY.value, columns, size);

if (oldOrder !== newOrder) {
const idToSwap = Object.keys(positions.value).find(
(key) => positions.value[key] === newOrder
);

if (idToSwap) {
const newPositions = JSON.parse(JSON.stringify(positions.value));
newPositions[id] = newOrder;
newPositions[idToSwap] = oldOrder;
positions.value = newPositions;
}
}

scrollLogic({ absoluteY });
})
.onFinalize(() => {
const destination = getPosition(positions.value[id] || 0, columns, size);

translateX.value = withTiming(
destination.x,
{
easing: Easing.inOut(Easing.ease),
duration: 350,
},
() => {
isGestureActive.value = false;

const newPositions = Object.keys(positions.value).map((key) => positions.value[key]);

runOnJS(onDragEnd)(newPositions.map(String));
}
);

translateY.value = withTiming(destination.y, {
easing: Easing.inOut(Easing.ease),
duration: 350,
});
});

const style = useAnimatedStyle(() => {
const zIndex = isGestureActive.value ? 100 : 0;
const scale = isGestureActive.value ? 1.1 : 1;

return {
position: 'absolute',
top: 0,
left: 0,
width: size,
height: size,
zIndex,
transform: [{ translateX: translateX.value }, { translateY: translateY.value }, { scale }],
};
});

return (
<GestureDetector gesture={panGesture}>
<Animated.View style={style}>{children}</Animated.View>
</GestureDetector>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { FlashList } from '@shopify/flash-list';
import { useMemo } from 'react';
import { View, ViewProps } from 'react-native';
import { AnimatedRef, SharedValue, useSharedValue } from 'react-native-reanimated';

import { StagedItem } from '~/contexts/GalleryEditor/types';

import { ListItemType } from '../GalleryEditorRenderer';
import { GalleryEditorTokenPreview } from '../GalleryEditorTokenPreview';
import { SortableToken } from './SortableToken';
import { Positions } from './utils';

type Props = {
columns: number;
items: StagedItem[];
size: number;

scrollContentOffsetY: SharedValue<number>;
scrollViewRef: AnimatedRef<FlashList<ListItemType>>;

onDragEnd: (data: string[]) => void;
};

export function SortableTokenGrid({
columns,
items,
size,
scrollContentOffsetY,
scrollViewRef,
onDragEnd,
}: Props) {
const positions = useSharedValue<Positions>(
Object.assign({}, ...items.map((item, index) => ({ [item.id]: index })))
);

const containerHeight = useMemo(() => {
return Math.ceil(items.length / columns) * size;
}, [items.length, columns, size]);

return (
<View
style={{
height: containerHeight,
width: '100%',
}}
>
{items.map((item) => (
<SortableToken
key={item.id}
id={item.id}
positions={positions}
size={size}
columns={columns}
scrollContentOffsetY={scrollContentOffsetY}
scrollViewRef={scrollViewRef}
onDragEnd={onDragEnd}
>
<View>
{item.kind === 'whitespace' ? (
<WhiteSpace size={size - 8} />
) : (
<View
className="aspect-square"
style={{
width: size - 8,
}}
>
<GalleryEditorTokenPreview tokenRef={item.tokenRef} />
</View>
)}
</View>
</SortableToken>
))}
</View>
);
}

type WhiteSpaceProps = {
size: number;
style?: ViewProps['style'];
};

function WhiteSpace({ size, style }: WhiteSpaceProps) {
return <View style={[{ width: size, height: size }, style]} />;
}
Loading

0 comments on commit d2121e7

Please sign in to comment.