feat: Add Drag and Drop Support in Categories Screen

Closes #1592
This commit is contained in:
Rajarshee Chatterjee
2025-10-22 19:49:45 +05:30
parent 259dd6acc7
commit 3785dddebc
6 changed files with 137 additions and 110 deletions
+15
View File
@@ -47,6 +47,7 @@
"react-native": "0.79.2",
"react-native-background-actions": "^4.0.1",
"react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.2",
"react-native-drawer-layout": "^4.1.8",
"react-native-edge-to-edge": "^1.6.0",
"react-native-error-boundary": "^2.0.0",
@@ -13381,6 +13382,20 @@
"@babel/runtime": "^7.20.6"
}
},
"node_modules/react-native-draggable-flatlist": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.3.tgz",
"integrity": "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==",
"license": "MIT",
"dependencies": {
"@babel/preset-typescript": "^7.17.12"
},
"peerDependencies": {
"react-native": ">=0.64.0",
"react-native-gesture-handler": ">=2.0.0",
"react-native-reanimated": ">=2.8.0"
}
},
"node_modules/react-native-drawer-layout": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.1.8.tgz",
+1
View File
@@ -74,6 +74,7 @@
"react-native": "0.79.2",
"react-native-background-actions": "^4.0.1",
"react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.2",
"react-native-drawer-layout": "^4.1.8",
"react-native-edge-to-edge": "^1.6.0",
"react-native-error-boundary": "^2.0.0",
+43 -38
View File
@@ -1,7 +1,10 @@
import { FlatList, StyleSheet } from 'react-native';
import { StyleSheet } from 'react-native';
import React, { useEffect } from 'react';
import { FAB } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import DraggableFlatList, {
RenderItemParams,
} from 'react-native-draggable-flatlist';
import { Appbar, EmptyView, SafeAreaView } from '@components/index';
import AddCategoryModal from './components/AddCategoryModal';
@@ -12,10 +15,10 @@ import { useTheme } from '@hooks/persisted';
import { getString } from '@strings/translations';
import CategoryCard from './components/CategoryCard';
import { orderBy } from 'lodash-es';
import CategorySkeletonLoading from './components/CategorySkeletonLoading';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useLibraryContext } from '@components/Context/LibraryContext';
import { ExtendedCategory } from '@screens/library/hooks/useLibrary';
const CategoriesScreen = () => {
const { categories, setCategories, refreshCategories, isLoading } =
@@ -35,35 +38,41 @@ const CategoriesScreen = () => {
refreshCategories();
}, [refreshCategories]);
const updateCategorySort = (currentIndex: number, newIndex: number) => {
// Do not set local as default one
if (
(newIndex === 0 &&
currentIndex === 1 &&
categories?.[currentIndex].id === 2) ||
(newIndex === 1 && currentIndex === 0 && categories?.[newIndex].id === 2)
) {
const userCategories = React.useMemo(() => {
if (!categories || categories.length === 0) {
return [];
}
return categories.filter(cat => cat.id !== 1);
}, [categories]);
const onDragEnd = ({ data }: { data: ExtendedCategory[] }) => {
if (!categories || categories.length === 0) {
return;
}
const updatedOrderCategories = orderBy(
categories?.map((category, index) => {
// + 1 because in db, sort start from 1
// swap adjacent cards
if (index === currentIndex) {
return { ...category, sort: newIndex + 1 };
}
if (index === newIndex) {
return { ...category, sort: currentIndex + 1 };
}
return category;
}),
'sort',
);
setCategories(updatedOrderCategories);
updateCategoryOrderInDb(updatedOrderCategories || []);
const systemCategories = categories.filter(cat => cat.id === 1);
const updatedOrderCategories = [...systemCategories, ...data];
requestAnimationFrame(() => {
setCategories(updatedOrderCategories);
updateCategoryOrderInDb(updatedOrderCategories);
});
};
const renderItem = ({
item,
drag,
isActive,
}: RenderItemParams<ExtendedCategory>) => (
<CategoryCard
category={item}
getCategories={refreshCategories}
drag={drag}
isActive={isActive}
/>
);
return (
<SafeAreaView excludeTop>
<Appbar
@@ -74,18 +83,14 @@ const CategoriesScreen = () => {
{isLoading ? (
<CategorySkeletonLoading width={360.7} height={89.5} theme={theme} />
) : (
<FlatList
data={categories}
<DraggableFlatList
data={userCategories}
contentContainerStyle={styles.contentCtn}
renderItem={({ item, index }) => (
<CategoryCard
category={item}
getCategories={refreshCategories}
categoryIndex={index}
updateCategorySort={updateCategorySort}
totalCategories={categories?.length || 0}
/>
)}
renderItem={renderItem}
keyExtractor={(item, index) => `${item.id}-${index}`}
onDragEnd={onDragEnd}
activationDistance={10}
autoscrollSpeed={100}
ListEmptyComponent={
<EmptyView
icon="Σ(ಠ_ಠ)"
@@ -118,7 +123,7 @@ export default CategoriesScreen;
const styles = StyleSheet.create({
contentCtn: {
flexGrow: 1,
paddingBottom: 100,
paddingBottom: 270,
paddingVertical: 16,
},
fab: {
@@ -1,28 +1,27 @@
import { StyleSheet, Text, View } from 'react-native';
import React from 'react';
import { TouchableOpacity } from 'react-native-gesture-handler';
import { Category } from '@database/types';
import { useTheme } from '@hooks/persisted';
import AddCategoryModal from './AddCategoryModal';
import { useBoolean } from '@hooks';
import { overlay, Portal } from 'react-native-paper';
import { Badge, Portal } from 'react-native-paper';
import IconButton from '@components/IconButtonV2/IconButtonV2';
import DeleteCategoryModal from './DeleteCategoryModal';
interface CategoryCardProps {
categoryIndex: number;
category: Category;
getCategories: () => Promise<void>;
updateCategorySort: (currentIndex: number, newIndex: number) => void;
totalCategories: number;
drag: () => void;
isActive: boolean;
}
const CategoryCard: React.FC<CategoryCardProps> = ({
category,
getCategories,
categoryIndex,
updateCategorySort,
totalCategories,
drag,
isActive,
}) => {
const theme = useTheme();
@@ -38,80 +37,76 @@ const CategoryCard: React.FC<CategoryCardProps> = ({
setFalse: closeDeleteCategoryModal,
} = useBoolean();
const fontWeight = category.sort === 1 ? '700' : '300';
return (
<>
<View
style={[
styles.cardCtn,
{
backgroundColor:
category.sort === 1
? theme.isDark
? overlay(2, theme.surface2)
: theme.primaryContainer
: category.id === 2
? theme.tertiaryContainer
: theme.secondaryContainer,
backgroundColor: theme.secondaryContainer,
opacity: isActive ? 0.8 : 1,
elevation: isActive ? 8 : 2,
},
]}
>
<View style={styles.nameCtn}>
<IconButton
name="label-outline"
color={theme.onSurface}
padding={0}
theme={theme}
/>
<Text
style={[
styles.name,
{
color: theme.onSurface,
fontWeight,
},
]}
onPress={showCategoryModal}
>
{category.name}
</Text>
</View>
<View style={styles.buttonsCtn}>
<IconButton
name="menu-up"
color={theme.onSurface}
onPress={() => updateCategorySort(categoryIndex, categoryIndex - 1)}
theme={theme}
disabled={
categoryIndex <= 0 || (categoryIndex === 1 && category.id === 2)
}
/>
<IconButton
name="menu-down"
color={theme.onSurface}
style={styles.orderDownBtn}
onPress={() => updateCategorySort(categoryIndex, categoryIndex + 1)}
theme={theme}
disabled={categoryIndex + 1 >= totalCategories}
/>
<TouchableOpacity
onLongPress={drag}
style={styles.dragHandle}
activeOpacity={0.6}
>
<IconButton
name="drag-horizontal-variant"
color={theme.onSurface}
theme={theme}
padding={8}
/>
</TouchableOpacity>
<View style={styles.nameCtn}>
<Text
style={[
styles.name,
{
color: theme.onSurface,
},
]}
onPress={showCategoryModal}
disabled={category.id === 2}
>
{category.name}
</Text>
{category.id === 2 && (
<Badge
style={{
backgroundColor: theme.primary,
paddingHorizontal: 8,
}}
>
System
</Badge>
)}
</View>
<View style={styles.flex} />
<IconButton
name="pencil-outline"
color={theme.onSurface}
style={styles.manageBtn}
onPress={showCategoryModal}
theme={theme}
/>
{categoryIndex !== 0 && category.id !== 2 ? (
<View style={{ opacity: category.id === 2 ? 0.4 : 1 }}>
<IconButton
name="pencil-outline"
color={category.id === 2 ? theme.outline : theme.onSurface}
style={styles.manageBtn}
onPress={showCategoryModal}
theme={theme}
disabled={category.id === 2}
/>
</View>
<View style={{ opacity: category.id === 2 ? 0.4 : 1 }}>
<IconButton
name="delete-outline"
color={theme.onSurface}
color={category.id === 2 ? theme.outline : theme.onSurface}
style={styles.manageBtn}
onPress={showDeleteCategoryModal}
theme={theme}
disabled={category.id === 2}
/>
) : null}
</View>
</View>
</View>
<Portal>
@@ -142,12 +137,14 @@ const styles = StyleSheet.create({
},
cardCtn: {
borderRadius: 12,
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.25)',
marginBottom: 8,
marginHorizontal: 16,
paddingHorizontal: 8,
paddingVertical: 8,
},
dragHandle: {
marginRight: 4,
},
flex: {
flex: 1,
},
@@ -155,17 +152,15 @@ const styles = StyleSheet.create({
marginLeft: 16,
},
name: {
marginHorizontal: 16,
marginLeft: 16,
marginRight: 8,
},
nameCtn: {
alignItems: 'center',
flex: 1,
flexGrow: 1,
flexDirection: 'row',
marginLeft: 8,
paddingRight: 16,
paddingVertical: 4,
},
orderDownBtn: {
marginLeft: 8,
},
});
+12 -1
View File
@@ -45,7 +45,18 @@ export const useLibrary = (): UseLibraryReturnType => {
novelIds: (c.novelIds ?? '').split(',').map(Number),
}));
setCategories(res);
const filteredCategories = res.filter((cat, index) => {
if (index !== 0) {
return true;
}
const hasUserCategories = res.length > 2;
const hasNovels = cat.novelIds.length > 0 && cat.novelIds[0] !== 0;
return !hasUserCategories || hasNovels;
});
setCategories(filteredCategories);
}, []);
const getLibrary = useCallback(async () => {
+1 -1
View File
@@ -12,7 +12,7 @@ import Animated, {
useAnimatedStyle,
} from 'react-native-reanimated';
import ExportNovelAsEpubButton from './ExportNovelAsEpubButton';
import { ChapterInfo, NovelInfo } from '@database/types';
import { NovelInfo } from '@database/types';
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
import { MaterialDesignIconName } from '@type/icon';