Skip to content

feat: Fixed items support for Sortable.Grid #310

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 23, 2025
Merged

Conversation

MatiPl01
Copy link
Owner

@MatiPl01 MatiPl01 commented Mar 20, 2025

Description

This PR adds support for fixed items for the Sortable.Grid component.

Thanks @tpaksu for a proposed solution in #305

Example recordings

  • Example 1 - without data change
  • Example 2 - with data change (added/removed/inserted items)
Example 1 Example 2
Screen.Recording.2025-03-20.at.17.01.14.mp4
Screen.Recording.2025-03-20.at.17.15.55.mp4
Example 1 code snippet
import { useCallback } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import type { SortableGridRenderItem } from 'react-native-sortables';
import Sortable from 'react-native-sortables';

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

const DATA = Array.from({ length: 12 }, (_, index) => `Item ${index + 1}`);

export default function PlaygroundExample() {
  const renderItem = useCallback<SortableGridRenderItem<string>>(
    ({ item, index }) => {
      const fixed =
        index === 0 || index === 4 || index === 9 || index === DATA.length - 1;
      return (
        <Sortable.Handle mode={fixed ? 'fixed' : 'draggable'}>
          <View
            style={[
              styles.card,
              {
                backgroundColor: fixed ? colors.secondary : colors.primary
              }
            ]}>
            <Text style={styles.text}>{item}</Text>
          </View>
        </Sortable.Handle>
      );
    },
    []
  );

  return (
    <ScrollScreen contentContainerStyle={styles.container} includeNavBarHeight>
      <Sortable.Grid
        columnGap={10}
        columns={3}
        data={DATA}
        customHandle
        renderItem={renderItem}
        rowGap={10}
      />
    </ScrollScreen>
  );
}

const styles = StyleSheet.create({
  card: {
    alignItems: 'center',
    borderRadius: radius.md,
    height: sizes.xl,
    justifyContent: 'center'
  },
  container: {
    padding: spacing.md
  },
  text: {
    ...text.label2,
    color: colors.white
  }
});
Example 2 code snippet
import { memo, useCallback, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Animated, { useAnimatedRef } from 'react-native-reanimated';
import Sortable, { type SortableGridRenderItem } from 'react-native-sortables';

import {
  Button,
  GridCard,
  Group,
  Screen,
  Section,
  Stagger
} from '@/components';
import { IS_WEB } from '@/constants';
import { colors, flex, spacing, text } from '@/theme';
import { getItems } from '@/utils';

const AVAILABLE_DATA = getItems(18);
const COLUMNS = 4;

const FIXED_KEYS = new Set(['Item 1', 'Item 2', 'Item 3', 'Item 4']);

export default function DataChangeExample() {
  const scrollableRef = useAnimatedRef<Animated.ScrollView>();
  const [data, setData] = useState(AVAILABLE_DATA.slice(0, 12));

  const getNewItemName = useCallback((currentData: Array<string>) => {
    if (currentData.length >= AVAILABLE_DATA.length) {
      return null;
    }
    for (const item of AVAILABLE_DATA) {
      if (!currentData.includes(item)) {
        return item;
      }
    }
    return null;
  }, []);

  const prependItem = useCallback(() => {
    setData(prevData => {
      const newItem = getNewItemName(prevData);
      if (newItem) {
        return [newItem, ...prevData];
      }
      return prevData;
    });
  }, [getNewItemName]);

  const insertItem = useCallback(() => {
    setData(prevData => {
      const newItem = getNewItemName(prevData);
      if (newItem) {
        const index = Math.floor(Math.random() * (prevData.length - 1));
        return [...prevData.slice(0, index), newItem, ...prevData.slice(index)];
      }
      return prevData;
    });
  }, [getNewItemName]);

  const appendItem = useCallback(() => {
    setData(prevData => {
      const newItem = getNewItemName(prevData);
      if (newItem) {
        return [...prevData, newItem];
      }
      return prevData;
    });
  }, [getNewItemName]);

  const shuffleItems = useCallback(() => {
    setData(prevData => {
      const shuffledData = [...prevData];
      for (let i = shuffledData.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [shuffledData[i], shuffledData[j]] = [
          shuffledData[j]!,
          shuffledData[i]!
        ];
      }
      return shuffledData;
    });
  }, []);

  const sortItems = useCallback(() => {
    setData(prevData =>
      [...prevData].sort((a, b) => +a.split(' ')[1]! - +b.split(' ')[1]!)
    );
  }, []);

  const onRemoveItem = useCallback((item: string) => {
    setData(prevData => prevData.filter(i => i !== item));
  }, []);

  const renderItem = useCallback<SortableGridRenderItem<string>>(
    ({ item }) => (
      <Sortable.Handle mode={FIXED_KEYS.has(item) ? 'fixed' : 'draggable'}>
        <GridItem
          item={item}
          onRemoveItem={onRemoveItem}
          fixed={FIXED_KEYS.has(item)}
        />
      </Sortable.Handle>
    ),
    [onRemoveItem]
  );

  const additionDisabled = data.length >= AVAILABLE_DATA.length;
  const reorderDisabled = data.length < 2;

  const menuSections = [
    {
      buttons: [
        { disabled: additionDisabled, onPress: prependItem, title: 'Prepend' },
        { disabled: additionDisabled, onPress: insertItem, title: 'Insert' },
        { disabled: additionDisabled, onPress: appendItem, title: 'Append' }
      ],
      description: 'Prepend/Insert/Append items to the list',
      title: 'Modify number of items'
    },
    {
      buttons: [
        { disabled: reorderDisabled, onPress: shuffleItems, title: 'Shuffle' },
        { disabled: reorderDisabled, onPress: sortItems, title: 'Sort' }
      ],
      description: 'Reorder items in the list',
      title: 'Change order of items'
    }
  ];

  return (
    <Screen includeNavBarHeight>
      {/* Need to set flex: 1 for the ScrollView parent component in order
      // to ensure that it occupies the entire available space */}
      <Stagger
        wrapperStye={index =>
          index === 2 ? (IS_WEB ? flex.shrink : flex.fill) : {}
        }>
        {menuSections.map(({ buttons, description, title }) => (
          <Section description={description} key={title} title={title}>
            <View style={styles.row}>
              {buttons.map(btnProps => (
                <Button {...btnProps} key={btnProps.title} />
              ))}
            </View>
          </Section>
        ))}

        <Group padding='none' style={[flex.fill, styles.scrollViewGroup]}>
          <Animated.ScrollView
            contentContainerStyle={styles.scrollViewContent}
            ref={scrollableRef}
            // @ts-expect-error - overflowY is needed for proper behavior on web
            style={[flex.fill, IS_WEB && { overflowY: 'scroll' }]}>
            <Group withMargin={false} bordered center>
              <Text style={styles.title}>Above SortableGrid</Text>
            </Group>

            <Sortable.Grid
              columnGap={spacing.sm}
              columns={COLUMNS}
              data={data}
              renderItem={renderItem}
              rowGap={spacing.xs}
              scrollableRef={scrollableRef}
              animateHeight
              customHandle
              hapticsEnabled
              onDragEnd={({ data: newData }) => setData(newData)}
            />

            <Group withMargin={false} bordered center>
              <Text style={styles.title}>Below SortableGrid</Text>
            </Group>
          </Animated.ScrollView>
        </Group>
      </Stagger>
    </Screen>
  );
}

type GridItemProps = {
  item: string;
  fixed: boolean;
  onRemoveItem: (item: string) => void;
};

// It is recommended to use memo for items to prevent re-renders of the entire grid
// on item order changes (renderItem takes and index argument, thus it must be called
// after every order change)
const GridItem = memo(function GridItem({
  item,
  onRemoveItem,
  fixed
}: GridItemProps) {
  return (
    <Sortable.Pressable onPress={onRemoveItem.bind(null, item)}>
      <GridCard style={fixed && { backgroundColor: '#999' }}>{item}</GridCard>
    </Sortable.Pressable>
  );
});

const styles = StyleSheet.create({
  row: {
    columnGap: spacing.sm,
    flexDirection: 'row',
    flexWrap: 'wrap',
    rowGap: spacing.xs
  },
  scrollViewContent: {
    gap: spacing.sm,
    padding: spacing.sm
  },
  scrollViewGroup: {
    overflow: 'hidden',
    paddingHorizontal: spacing.none,
    paddingVertical: spacing.none
  },
  title: {
    ...text.subHeading2,
    color: colors.foreground3
  }
});

@MatiPl01 MatiPl01 self-assigned this Mar 20, 2025
Copy link

vercel bot commented Mar 20, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

1 Skipped Deployment
Name Status Preview Comments Updated (UTC)
react-native-sortables-docs ⬜️ Ignored (Inspect) Visit Preview Mar 23, 2025 11:51am

@tpaksu
Copy link

tpaksu commented Mar 20, 2025

I think there's a difference between fixed and non-draggable settings of the handle, right? Can you also upload a video of the non-draggable setting? I think on shuffling, prepending, inserting, the items should also stay on their index in one of them, for example, if Item 1 is fixed, the insertion should insert an item into position 2, not before the first one. But I'm not sure about the append one.

This (and my solution also) doesn't fulfill the requirement of items staying at the end :) I couldn't think of a solution other than allowing GridItems as children of Sortable.Grid which will stay at the end.

@MatiPl01
Copy link
Owner Author

I think there's a difference between fixed and non-draggable settings of the handle, right? Can you also upload a video of the non-draggable setting?

Yeah, there is a difference. Basically, non-draggable items aren't draggable but their position can change as a result of other item's position change. fixed items cannot be dragged as well but their position also doesn't change if other items are re-ordered.

Here is a comparison:

non-draggable fixed
Screen.Recording.2025-03-21.at.19.24.48.mp4
Screen.Recording.2025-03-21.at.19.25.12.mp4

non-draggable works the same as the previous disabled property that I decided to remove.

I think on shuffling, prepending, inserting, the items should also stay on their index in one of them, for example, if Item 1 is fixed, the insertion should insert an item into position 2, not before the first one. But I'm not sure about the append one.

I don't think so. The order of items in the grid should be the same as the order of items in the data array. If you add the new item before other items causing fixed-position items position change, their position should change respectively. The order of data in the data array should be the source of truth.

If you don't want to move fixed position items, you should insert the new item at the desired index and ensure that fixed-position items are kept on their previous indices.

This (and my solution also) doesn't fulfill the requirement of items staying at the end :) I couldn't think of a solution other than allowing GridItems as children of Sortable.Grid which will stay at the end.

I think that it's ok to move the items if there is nothing between them. Sortable.Grid and Sortable.Flex don't allow items to be positioned out of flow (to have gaps between them). If someone needs to have a gap, they may render an empty grid/flex item before the fixed item rendered at the end of the grid.

@MatiPl01 MatiPl01 marked this pull request as ready for review March 23, 2025 11:51
@MatiPl01 MatiPl01 merged commit d0cb59e into main Mar 23, 2025
5 checks passed
@MatiPl01 MatiPl01 deleted the feat/fixed-items-support branch March 23, 2025 11:53
@rowdyrabbit
Copy link

This looks great! Any idea when it'll be added to a release?

@MatiPl01
Copy link
Owner Author

This looks great! Any idea when it'll be added to a release?

Likely tomorrow or even today. I need to add support for the Sortable.Flex component and examples to the example app before release.

MatiPl01 added a commit that referenced this pull request Mar 23, 2025
## Description

This PR fixed flex layout ordering logic that was incorrect. THe issue
started to appear after changing the `reorderInsert` function in the
#310 PR but was incorrectly implemented before.
MatiPl01 pushed a commit that referenced this pull request Mar 23, 2025
# [1.4.0](v1.3.2...v1.4.0) (2025-03-23)

### Bug Fixes

* Active item portal provider on web ([#312](#312)) ([d9660d2](d9660d2))
* Default keyExtractor behavior for numeric values ([#301](#301)) ([d7cf171](d7cf171))
* Flex ordering after recent changes ([#313](#313)) ([9df1fa5](9df1fa5)), closes [#310](#310)
* onPress not working after disabling drag ([#307](#307)) ([d1cbdc9](d1cbdc9)), closes [#306](#306)

### Features

* Active item portal to render item over all other content ([#299](#299)) ([ecfe289](ecfe289))
* Fixed items support for Sortable.Grid ([#310](#310)) ([d0cb59e](d0cb59e)), closes [#305](#305) [#999](https://github.com/MatiPl01/react-native-sortables/issues/999)
@MatiPl01
Copy link
Owner Author

🎉 This issue has been resolved in version 1.4.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Non-draggable items on the grid Order of Items in Grid Different from Order of Data Array Can we somehow do fixed items?
3 participants