Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion example/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,25 @@
"@fortawesome/free-solid-svg-icons": "6.5.2",
"@fortawesome/react-native-fontawesome": "0.3.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-menu/menu": "2.0.0",
"@react-navigation/bottom-tabs": "7.3.10",
"@react-navigation/native": "7.0.14",
"@react-navigation/native-stack": "7.2.0",
"@shopify/flash-list": "2.3.1",
"react": "19.2.3",
"react-native": "0.86.0",
"react-native-context-menu-view": "1.21.0",
"react-native-gesture-handler": "2.32.0",
"react-native-haptic-feedback": "2.3.3",
"react-native-ios-context-menu": "3.1.0",
"react-native-ios-utilities": "5.1.2",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.25.2",
"react-native-sortables": "workspace:*",
"react-native-svg": "15.15.4",
"react-native-worklets": "0.10.1"
"react-native-worklets": "0.10.1",
"zeego": "3.0.6"
},
"devDependencies": {
"@babel/core": "^7.25.2",
Expand Down
110 changes: 110 additions & 0 deletions example/app/src/examples/SortableGrid/tests/ContextMenuExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { MenuView } from '@react-native-menu/menu';
import { useCallback, useState } from 'react';
import { runOnJS, useAnimatedReaction } from 'react-native-reanimated';
import type { SortableGridRenderItem } from 'react-native-sortables';
import Sortable, { useItemContext } from 'react-native-sortables';

import { GridCard, Section } from '@/components';
import { spacing } from '@/theme';
import { getItems } from '@/utils';

const DATA = getItems(6);
const COLUMNS = 2;

// The native menu opens on the OS long-press timeout (~500ms, not tunable), so
// tune the sortable side: pick up sooner and treat a small move as a drag.
const DRAG_ACTIVATION_DELAY = 120; // ms until draggable (default 200)
// Small enough that the sortable claims the touch before the surrounding
// ScrollView starts scrolling, but larger than the finger jitter of a
// stationary long press (which must be left to the native context menu).
const DRAG_FAIL_OFFSET = 8; // px of movement that counts as a drag (default 5)

const MENU_ACTIONS = [
{ id: 'edit', title: 'Edit' },
{ attributes: { destructive: true }, id: 'delete', title: 'Delete' }
];

function noop() {
// Menu actions are no-ops in this example.
}

// Baseline for comparison: the native context menu on a PLAIN view (no
// sortable). Long press it to see how the OS lifts the view and keeps it
// scaled up while the menu is open.
function PlainMenuCard() {
return (
// Without an explicit size the native MenuView stretches to fill the row,
// so the long press triggers anywhere in the container, not just on the
// card. Size it to the card so the hit area matches what you see.
<MenuView
actions={MENU_ACTIONS}
style={{ alignSelf: 'flex-start', height: 150, width: 150 }}
title='Plain'
shouldOpenOnLongPress
onPressAction={noop}>
<GridCard height={150} width={150}>
Plain
</GridCard>
</MenuView>
);
}

function MenuCard({ item }: { item: string }) {
const { isDragging } = useItemContext();
const [dragging, setDragging] = useState(false);

useAnimatedReaction(
() => isDragging.value,
(value, prev) => {
if (value !== prev) {
runOnJS(setDragging)(value);
}
}
);

// While dragging, drop the menu so it can't open (and closes if already open).
// A stationary long press keeps it.
if (dragging) {
return <GridCard>{item}</GridCard>;
}

return (
<MenuView
actions={MENU_ACTIONS}
title={item}
shouldOpenOnLongPress
onPressAction={noop}>
<GridCard>{item}</GridCard>
</MenuView>
);
}

export default function ContextMenuExample() {
const renderItem = useCallback<SortableGridRenderItem<string>>(
({ item }) => <MenuCard item={item} />,
[]
);

return (
<>
<Section
description='Baseline (no sortable): long press this plain view. The OS lifts it and keeps it scaled up while the menu is open. Compare this with the sortable items below.'
title='Plain view + context menu'>
<PlainMenuCard />
</Section>
<Section
description='The same native context menu, but each item is a sortable. A long press should behave like the plain card above; a drag should reorder and dismiss the menu.'
title='Sortable items + context menu'>
<Sortable.Grid
columnGap={spacing.xs}
columns={COLUMNS}
data={DATA}
dragActivationDelay={DRAG_ACTIVATION_DELAY}
dragActivationFailOffset={DRAG_FAIL_OFFSET}
renderItem={renderItem}
rowGap={spacing.xs}
/>
</Section>
</>
);
}
124 changes: 124 additions & 0 deletions example/app/src/examples/SortableGrid/tests/ContextMenuViewExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Grid cards with a native iOS context menu (react-native-context-menu-view).
// Long press a card and the OS lifts it and opens the menu (it stays open); a
// drag reorders. Active drag-reorder and the native lift fundamentally fight -
// the drag arms on the long press and the OS cancels that touch when the menu
// commits - so the card shows a small scale-down settle as the menu opens.
import { useCallback, useState } from 'react';
import { View } from 'react-native';
import ContextMenu from 'react-native-context-menu-view';
import { runOnJS, useAnimatedReaction } from 'react-native-reanimated';
import type { SortableGridRenderItem } from 'react-native-sortables';
import Sortable, { useItemContext } from 'react-native-sortables';

import { GridCard, Section } from '@/components';
import { radius, spacing } from '@/theme';
import { getItems } from '@/utils';

const DATA = getItems(6);
const COLUMNS = 2;

// The native menu opens on the OS long-press timeout (~500ms, not tunable), so
// tune the sortable side: pick up sooner and treat a small move as a drag.
const DRAG_ACTIVATION_DELAY = 120; // ms until draggable (default 200)
const DRAG_FAIL_OFFSET = 8; // px of movement that counts as a drag (default 5)

const MENU_ACTIONS = [
{ systemIcon: 'pencil', title: 'Edit' },
{ destructive: true, systemIcon: 'trash', title: 'Delete' }
];

function noop() {
// Menu actions are no-ops in this example.
}

// Baseline (no sortable): the native menu lifts the card and keeps it scaled up
// while the menu is open.
function PlainMenuCard() {
return (
<ContextMenu
actions={MENU_ACTIONS}
borderRadius={radius.sm}
previewBackgroundColor='transparent'
style={{ alignSelf: 'flex-start' }}
disableShadow
onPress={noop}>
<GridCard height={150} width={150}>
Plain
</GridCard>
</ContextMenu>
);
}

function MenuCard({ item }: { item: string }) {
const { isDragging } = useItemContext();
const [dragging, setDragging] = useState(false);
// Grid cards stretch to fill their cell (no explicit size). When the OS lifts
// such a card for the preview it has nothing to size against and collapses to
// nothing (the card "disappears"), so render an explicitly sized `preview`.
// Measure the cell on a plain wrapper View - onLayout fires reliably there -
// and mirror that size into the preview card.
const [size, setSize] = useState({ height: 170, width: 170 });

useAnimatedReaction(
() => isDragging.value,
(value, prev) => {
if (value !== prev) {
runOnJS(setDragging)(value);
}
}
);

return (
// Keep the ContextMenu mounted and just disable it while dragging - a
// stationary long press keeps the menu; a drag drops it (and reorders).
<ContextMenu
actions={MENU_ACTIONS}
borderRadius={radius.sm}
disabled={dragging}
previewBackgroundColor='transparent'
preview={
<GridCard height={size.height} width={size.width}>
{item}
</GridCard>
}
disableShadow
onPress={noop}>
<View
onLayout={({ nativeEvent: { layout } }) =>
setSize({ height: layout.height, width: layout.width })
}>
<GridCard>{item}</GridCard>
</View>
</ContextMenu>
);
}

export default function ContextMenuViewExample() {
const renderItem = useCallback<SortableGridRenderItem<string>>(
({ item }) => <MenuCard item={item} />,
[]
);

return (
<>
<Section
description='Baseline (no sortable) using react-native-context-menu-view. Long press the card - the OS lifts it and keeps it scaled up (native UIContextMenuInteraction preview).'
title='Plain view + native context menu'>
<PlainMenuCard />
</Section>
<Section
description='Native context menu on sortable grid cards. Long press lifts a card and opens the menu; a drag reorders. The card settles with a small scale-down as the menu commits - the native lift and active drag-reorder fight over the same long press.'
title='Sortable cards + native context menu'>
<Sortable.Grid
columnGap={spacing.xs}
columns={COLUMNS}
data={DATA}
dragActivationDelay={DRAG_ACTIVATION_DELAY}
dragActivationFailOffset={DRAG_FAIL_OFFSET}
renderItem={renderItem}
rowGap={spacing.xs}
/>
</Section>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Sortable from 'react-native-sortables';

import { ScrollScreen } from '@/components';
import { colors, radius, spacing } from '@/theme';

import ContextMenuExample from './ContextMenuExample';
import ContextMenuViewExample from './ContextMenuViewExample';

// Two ways to attach a native iOS context menu to sortable grid items, one per
// tab. They behave the same on a long press; the difference is the library.
const TABS = [
{
Content: ContextMenuExample,
key: 'menu',
label: '@react-native-menu/menu'
},
{
Content: ContextMenuViewExample,
key: 'view',
label: 'react-native-context-menu-view'
}
];

export default function ContextMenusExample() {
const [activeKey, setActiveKey] = useState(TABS[0]!.key);
const ActiveContent = (TABS.find(tab => tab.key === activeKey) ?? TABS[0]!)
.Content;

return (
<ScrollScreen includeNavBarHeight>
<View style={styles.tabBar}>
{TABS.map(({ key, label }) => {
const active = key === activeKey;
return (
<Pressable
key={key}
style={[styles.tab, active && styles.tabActive]}
onPress={() => setActiveKey(key)}>
<Text
numberOfLines={1}
style={[styles.tabLabel, active && styles.tabLabelActive]}>
{label}
</Text>
</Pressable>
);
})}
</View>
{/* SortableLayer lets the dragged item render above the sections. */}
<Sortable.Layer>
<ActiveContent />
</Sortable.Layer>
</ScrollScreen>
);
}

const styles = StyleSheet.create({
tab: {
alignItems: 'center',
backgroundColor: colors.background3,
borderRadius: radius.sm,
flex: 1,
paddingHorizontal: spacing.xs,
paddingVertical: spacing.sm
},
tabActive: {
backgroundColor: colors.primary
},
tabBar: {
flexDirection: 'row',
gap: spacing.xs,
marginBottom: spacing.md
},
tabLabel: {
color: colors.foreground2,
fontSize: 12,
fontWeight: '600'
},
tabLabelActive: {
color: colors.background1
}
});
1 change: 1 addition & 0 deletions example/app/src/examples/SortableGrid/tests/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { default as BottomTabsNavigatorExample } from './BottomTabsNavigatorExample';
export { default as ContextMenusExample } from './ContextMenusExample';
export { default as HidingItemsExample } from './HidingItemsExample';
export { default as InputFocusExample } from './InputFocusExample';
export { default as MaxOverscrollOffsetExample } from './MaxOverscrollOffsetExample';
6 changes: 6 additions & 0 deletions example/app/src/examples/navigation/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ const routes: Routes = {
Component: SortableGrid.tests.BottomTabsNavigatorExample,
name: 'Bottom Tabs Navigator'
},
...(IS_IOS && {
ContextMenu: {
Component: SortableGrid.tests.ContextMenusExample,
name: 'Context Menu'
}
}),
HidingItems: {
Component: SortableGrid.tests.HidingItemsExample,
name: 'Hiding Items'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const { CommonValuesContext, CommonValuesProvider, useCommonValuesContext } =
const activeAnimationProgress = useMutableValue(0);
const inactiveAnimationProgress = useMutableValue(0);
const activeItemDropped = useMutableValue(true);
const activeItemBroughtToFront = useMutableValue(false);

// ITEM ACTIVATION SETTINGS
const dragActivationDelay = useAnimatableValue(_dragActivationDelay);
Expand Down Expand Up @@ -148,6 +149,7 @@ const { CommonValuesContext, CommonValuesProvider, useCommonValuesContext } =
activationAnimationDuration,
activationState,
activeAnimationProgress,
activeItemBroughtToFront,
activeItemDimensions,
activeItemDropped,
activeItemKey,
Expand Down
Loading
Loading