gitim / react-native-sortable-list

React Native Sortable List component
MIT License
884 stars 279 forks source link

Sectioned, Sortable List -- Possible? #88

Open embpdaniel opened 6 years ago

embpdaniel commented 6 years ago

Hi, I have a sectioned list (each section has it's own header). I need to sort rows within each of these sections, and need to disallow sorting a row outside of its corresponding section. Is it possible with this library?

i8wu commented 6 years ago

So make a new sortable list for each section?

embpdaniel commented 6 years ago

Exactly

i8wu commented 6 years ago

Are you having issues creating multiple lists then? The only real problem I see is if you want the whole thing to be scrollable, because you'll essentially have multiple scrollables inside one big scrollable, which probably won't work well.

embpdaniel commented 6 years ago

Right, that was exactly my concern. I was thinking of using SectionList each with a render item being the sortable list so that I can have sortable sections. But SectionList needs to scroll. I hadn't tried it yet but I was concerned about performance and if it works anyway.

i8wu commented 6 years ago

I'd wager the performance won't be great, but you can try it and see

embpdaniel commented 6 years ago

Will try it. thanks

embpdaniel commented 6 years ago

Been setting this up. So far it seems to be working ok and I have been able to sort my sectioned lists using simple views, however when I use the actual views I need, I have one problem. My child row has a TouchableOpacity component because I need to be able to press on the item. It will not let me sort the row now because I guess it creates a conflict with touches. I have set up the manual row activation as you suggest in the documentation, and I see that the toggleRowActive method is provided to my child. I call it on the TouchableOpacity onLongPress and I call it again on the TouchableOpacity onPressOut, since it is a toggle I figure it will activate after long press and de-activate when I let go. This is not working though. The long press elevates my item, as if it is ready to drag, but when I try to drag, nothing happens. Here is how I have it:

Main.js

<SortableList
            style={styles.list}
            contentContainerStyle={styles.contentContainer}
            renderRow={this._renderRoomsRow}
            data={item}
            onActivateRow={this._onActivateRow}
            onChangeOrder={this._onRoomsChangeOrder}
            onReleaseRow={this._onReleaseRow}
            scrollEnabled={false}
            manuallyActivateRows={true}
          />
 _renderRoomsRow = ({ data, active, toggleRowActive }) => {
    return (
      <SortableRow active={active} toggleRowActive={toggleRowActive}>
        <RoomItem item={data} onPress={this._onRoomPressed} />
      </SortableRow>
    );
  };

SortableRow.js

import React, { Component } from 'react';
import { Animated, Platform, Easing } from 'react-native';
import PropTypes from 'prop-types';
/**
 * List row for SortableList component.
 */
export default class SortableRow extends Component {
  constructor(props) {
    super(props);

    this._active = new Animated.Value(0);
    this._rowStyle = {
      flex: 1,
      width: window.width,
      ...Platform.select({
        android: {
          elevation: 0
        }
      })
    };

    this._style = {
      ...Platform.select({
        ios: {
          transform: [
            {
              scale: this._active.interpolate({
                inputRange: [0, 1],
                outputRange: [1, 1.1]
              })
            }
          ],
          shadowRadius: this._active.interpolate({
            inputRange: [0, 1],
            outputRange: [2, 10]
          })
        },

        android: {
          transform: [
            {
              scale: this._active.interpolate({
                inputRange: [0, 1],
                outputRange: [1, 1.07]
              })
            }
          ],
          elevation: this._active.interpolate({
            inputRange: [0, 1],
            outputRange: [2, 6]
          })
        }
      })
    };
  }

  componentWillReceiveProps(nextProps) {
    if (this.props.active !== nextProps.active) {
      Animated.timing(this._active, {
        duration: 300,
        easing: Easing.bounce,
        toValue: Number(nextProps.active)
      }).start();
    }
  }

  render() {
    const childrenWithProps = this.props.children
      ? React.Children.map(this.props.children, child =>
          React.cloneElement(child, { onLongPress: this.props.toggleRowActive, onPressOut: this.props.toggleRowActive })
        )
      : this.props.children;
    return <Animated.View style={[this._rowStyle, this._style]}>{childrenWithProps}</Animated.View>;
  }
}

SortableRow.propTypes = {
  active: PropTypes.bool,
  children: PropTypes.any,
  toggleRowActive: PropTypes.func
};

RoomItem.js

import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import PropTypes from 'prop-types';
import styles from './styles/RoomItem';
import CustomIcon from './CustomIcon';

/**
 * Renders a list item for a room.
 */
const RoomItem = props => {
  const { item, onPress, onLongPress, onPressOut } = props;
  return (
    <TouchableOpacity
      style={styles.container}
      onPress={() => onPress(item)}
      onLongPress={onLongPress}
      onPressOut={onPressOut}
    >
      <CustomIcon name={item.private ? 'lock' : 'globe'} size={15} />
      <View style={styles.details}>
        <Text style={styles.name}>{item.name}</Text>
      </View>
    </TouchableOpacity>
  );
};

RoomItem.defaultProps = {
  item: {},
  onLongPress: () => {},
  onPressOut: () => {}
};

RoomItem.propTypes = {
  item: PropTypes.object,
  onPress: PropTypes.func.isRequired,
  onLongPress: PropTypes.func,
  onPressOut: PropTypes.func
};

export default RoomItem;

Hope you can help, thanks

embpdaniel commented 6 years ago

Ah, I fixed this. I'm not supposed to call the toggleRowActive when I release as that gets handled on its own :)

embpdaniel commented 6 years ago

@i8wu So I have been able to use two lists within a SectionList component and they have worked fine. I only had two sortable lists to render so it has worked well for me.

The one issue that I am having trouble with is that these lists may be sorted remotely, by the web version of the app. When the changes come in, the sortable list that was updated goes blank for about a second and re-draws, which seems unnecessary because items weren't added or removed, they just changed order. Is there a way to keep it from re-rendering?

embpdaniel commented 6 years ago

In fact, sorting them causes it to blink too because I sort them, and must send the update out to the web app, at the same time the update echos through a real-time state checker which in turn updates my redux store, the component picks up on the change and re-renders because its data was not current. Maybe I'm not understanding how I'm supposed to handle this sort of flow with the sorting component. If you could help direct me in the right direction I would be really grateful

i8wu commented 6 years ago

Not sure...maybe you could try not syncing immediately to your app? Possibly have it sync on resume or something? You could also try another list module maybe https://github.com/deanmcpherson/react-native-sortable-listview I'm not actively using this module anymore so I can't really comment on usage/performance.

embpdaniel commented 6 years ago

@i8wu I did try that list too, it doesn't do the blink, but the sorting interaction doesn't play nice with nested lists :( Maybe I will fork your library and try to fix if I can't find another way around it.

embpdaniel commented 6 years ago

I was able to get this working now and haven't encountered issues, by using the order property and re-using the previous order lists, if the order never changed. I'll explain what I did in case it helps someone:

Overall logic:

  1. User long presses an item
  2. Main SectionList scrolling disables
  3. Sorting is activated manually
  4. User can drag
  5. User let's go, sorting is disabled automatically
  6. New sorting is reported (to API/Redux, etc...)
  7. SectionList scrolling re-enables
  8. MyComponent detects a data change (the new ordered items came in through props)
  9. Since we already have the correct order, just re-use the same array, this keeps it from re-rendering
  10. If data actually change then yes, use the new order array (somehow with my last updates, even if the data had changed, the re-rendering blink disappeared completely, so that's a plus)

Issues to note: The auto scroll on sort does not work, I had to disable scrolling on each sortable list to get what I needed. But compared to other issues I could have this is no biggie at all.

Here's the code:

The main component/screen

import React, { Component } from 'react';
import { Text, TouchableOpacity, View, SectionList, ActivityIndicator } from 'react-native';
import PropTypes from 'prop-types';
import NavigationBar from 'react-native-navbar';
import Icon from 'react-native-vector-icons/FontAwesome';
import SortableList from 'react-native-sortable-list';
import _ from 'lodash';
import MenuButton from './MenuButton';
import SectionHeader from './SectionHeader';
import PersonItem from './PersonItem';
import RoomItem from './RoomItem';
import SortableRow from './SortableRow';
import styles from './styles/MyComponent';
import * as Colors from '../theme/Colors';
import { arrToKeys, arrToObject } from '../utils/data';
import { CHAT_TYPE_DIRECT, CHAT_TYPE_ROOM } from '../constants/app';
import { SCREEN_CHAT } from '../constants/navigationConstants';

const CATEGORY_ROOMS = 'ROOMS';
const CATEGORY_PEOPLE = 'PEOPLE';

export default class MyComponent extends Component {
  constructor(props) {
    super(props);
    this.state = {
      sections: [],
      scrollEnabled: true,
      roomsOrder: [],
      usersOrder: []
    };
  }
  componentDidMount() {
    this._updateSections(this.props);
  }
  componentWillReceiveProps(nextProps) {
    this._updateSections(nextProps);
  }
  _updateSections = props => {
    const { rooms, users } = props;
    let roomsOrder = arrToKeys(rooms, 'id');
    let usersOrder = arrToKeys(users, 'id');
    let sections = [];
    sections.push({ title: CATEGORY_ROOMS, data: [rooms] });
    sections.push({ title: CATEGORY_PEOPLE, data: [users] });
    let roomsCollection = arrToObject(rooms, 'id');
    let usersCollection = arrToObject(users, 'id');
    this.setState({
      sections,
      roomsOrder,
      usersOrder,
      rooms: !_.isEqual(roomsCollection, this.state.rooms) ? roomsCollection : this.state.rooms,
      users: !_.isEqual(usersCollection, this.state.users) ? usersCollection : this.state.users
    });
  };
  _title() {
    return (
      <Text testID={'Title'} style={styles.navBarTitle}>
        Connect
      </Text>
    );
  }

  _leftButton() {
    return <MenuButton onPress={() => this.props.toggleMenu()} />;
  }

  _rightButton() {
    return (
      <TouchableOpacity style={styles.touchableOpacity}>
        <Icon name="bell-o" size={32} color={'white'} />
      </TouchableOpacity>
    );
  }
  _renderLoading = () => {
    return (
      <View style={styles.loadingContainer}>
        <View>
          <ActivityIndicator size={'large'} />
          <Text style={styles.loadingText}>Loading</Text>
        </View>
      </View>
    );
  };
  _renderSectionHeader({ section }) {
    return <SectionHeader section={section} />;
  }

  _renderItem = ({ item, section }) => {
    let element;
    switch (section.title) {
      case CATEGORY_PEOPLE:
        element = (
          <SortableList
            style={styles.list}
            contentContainerStyle={styles.contentContainer}
            renderRow={this._renderUsersRow}
            data={this.state.users}
            onActivateRow={this._onActivateRow}
            onChangeOrder={this._onUsersChangeOrder}
            onReleaseRow={this._onReleaseRow}
            scrollEnabled={false}
            manuallyActivateRows={true}
            order={this.state.usersOrder}
          />
        );
        break;
      case CATEGORY_ROOMS:
        element = (
          <SortableList
            style={styles.list}
            contentContainerStyle={styles.contentContainer}
            renderRow={this._renderRoomsRow}
            data={this.state.rooms}
            onActivateRow={this._onActivateRow}
            onChangeOrder={this._onRoomsChangeOrder}
            onReleaseRow={this._onReleaseRow}
            scrollEnabled={false}
            manuallyActivateRows={true}
            order={this.state.roomsOrder}
          />
        );
        break;
    }

    return element;
  };

  _renderUsersRow = ({ data, active, toggleRowActive }) => {
    return (
      <SortableRow active={active} toggleRowActive={toggleRowActive}>
        <PersonItem disabled={active} item={data} term={'me'} onPress={this._onPersonPressed} infoType="bookmark" />
      </SortableRow>
    );
  };

  _renderRoomsRow = ({ data, active, toggleRowActive }) => {
    return (
      <SortableRow active={active} toggleRowActive={toggleRowActive}>
        <RoomItem item={data} onPress={this._onRoomPressed} />
      </SortableRow>
    );
  };

  _onActivateRow = () => {
    this.setState({ scrollEnabled: false });
  };

  _onPersonPressed = person => {
    this.props.navigation.navigate(SCREEN_CHAT, {
      type: CHAT_TYPE_DIRECT,
      id: person.id,
      name: person.full_name
    });
  };

  _onRoomPressed = room => {
    this.props.navigation.navigate(SCREEN_CHAT, {
      type: CHAT_TYPE_ROOM,
      id: room.id,
      name: room.name,
      isPrivate: room.private
    });
  };

  _onUsersChangeOrder = nextOrder => {
    this.setState({ usersOrder: nextOrder });
  };

  _onRoomsChangeOrder = nextOrder => {
    this.setState({ roomsOrder: nextOrder });
  };

  _onReleaseRow = () => {
    this.props.reportLatestBookmarksOrder(this.state.usersOrder, this.state.roomsOrder);
    this.setState({ scrollEnabled: true });
  };

  render() {
    return this.props.stateIsLoading ? (
      this._renderLoading()
    ) : this.state.sections.length ? (
      <View style={styles.container}>
        <SectionList
          style={styles.sectionList}
          keyExtractor={(item, index) => item.id}
          renderItem={this._renderItem}
          renderSectionHeader={this._renderSectionHeader}
          sections={this.state.sections}
          stickySectionHeadersEnabled={true}
          scrollEnabled={this.state.scrollEnabled}
        />
      </View>
    ) : null;
  }
}

MyComponent.propTypes = {
  rooms: PropTypes.array.isRequired,
  users: PropTypes.array.isRequired,
  reportLatestBookmarksOrder: PropTypes.func.isRequired,
  stateIsLoading: PropTypes.bool.isRequired
};

The SortableRow component

import React, { Component } from 'react';
import { Animated, Platform, Easing } from 'react-native';
import PropTypes from 'prop-types';
/**
 * List row for SortableList component.
 */
export default class SortableRow extends Component {
  constructor(props) {
    super(props);

    this._active = new Animated.Value(0);
    this._rowStyle = {
      flex: 1,
      width: window.width,
      ...Platform.select({
        android: {
          elevation: 0
        }
      })
    };

    this._style = {
      ...Platform.select({
        ios: {
          transform: [
            {
              scale: this._active.interpolate({
                inputRange: [0, 1],
                outputRange: [1, 1.1]
              })
            }
          ],
          shadowRadius: this._active.interpolate({
            inputRange: [0, 1],
            outputRange: [2, 10]
          })
        },

        android: {
          transform: [
            {
              scale: this._active.interpolate({
                inputRange: [0, 1],
                outputRange: [1, 1.07]
              })
            }
          ],
          elevation: this._active.interpolate({
            inputRange: [0, 1],
            outputRange: [2, 6]
          })
        }
      })
    };
  }

  componentWillReceiveProps(nextProps) {
    if (this.props.active !== nextProps.active) {
      Animated.timing(this._active, {
        duration: 300,
        easing: Easing.bounce,
        toValue: Number(nextProps.active)
      }).start();
    }
  }

  render() {
    const childrenWithProps = this.props.children
      ? React.Children.map(this.props.children, child =>
          React.cloneElement(child, { onLongPress: this.props.toggleRowActive })
        )
      : this.props.children;
    return <Animated.View style={[this._rowStyle, this._style]}>{childrenWithProps}</Animated.View>;
  }
}

SortableRow.propTypes = {
  active: PropTypes.bool,
  children: PropTypes.any,
  toggleRowActive: PropTypes.func
};

The actual List Items, wrapped by the SortableRow component

import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import _ from 'lodash';
import PropTypes from 'prop-types';
import ConnectStatus from './ConnectStatus';
import styles from './styles/PersonItem';
import Avatar from './Avatar';

const PersonItem = props => {
  const { item, term, onPress, infoType, onLongPress } = props;
  let info;

  switch (infoType) {
    case 'bookmark':
      info = `${item.territory} • ${item.title}`;
      break;
    default:
      info = `${item.title} ${item.department} ${item.territory} ${item.branch}`;
  }
// ** The important part here is that I set the onLongPress of this inner touchable opacity, so I can tap the item to go to another screen, and longPress it to sort it **
  return (
    <TouchableOpacity onLongPress={onLongPress} style={styles.container} onPress={() => onPress(item)}>
      <View style={styles.details}>
        <Avatar user={item} />
        <View style={styles.info}>
          <View style={styles.nameContainer}>
           <Text style={styles.name}>{item.full_name}</Text>
            <ConnectStatus userId={item.id} />
          </View>
          <Text style={styles.position}>{info}</Text>
        </View>
      </View>
    </TouchableOpacity>
  );
};

PersonItem.defaultProps = {
  infoType: 'default',
  onLongPress: () => {}
};

PersonItem.propTypes = {
  item: PropTypes.object,
  term: PropTypes.string,
  onPress: PropTypes.func.isRequired,
  infoType: PropTypes.oneOf(['bookmark', 'default']),
  onLongPress: PropTypes.func
};

export default PersonItem;

The RoomItem list item is about the same as PersonItem, so I won't paste that.

ppetrick commented 6 years ago

@embpdaniel I tried implementing something similar, but didn't have much luck getting rid of the blinking every time Redux updates the data (in your case updating rooms or users). Any ideas what in particular stopped the blinking?

embpdaniel commented 6 years ago

hey @ppetrick sorry just saw your reply. It's been a while since I worked on this, but I believe the key part was basically not re-applying the list data unless an item was added/removed, and making use of the list's order property to affect list ordering. If an item was simply re-ordered, then the existing list data could still be re-used, and I would simply use the ordering arrays to tell the list component to re-order. The key spot this happens in the code I pasted is in the _updateSections function:

_updateSections = props => {
    const { rooms, users } = props;
    let roomsOrder = arrToKeys(rooms, 'id');
    let usersOrder = arrToKeys(users, 'id');
    let sections = [];
    sections.push({ title: CATEGORY_ROOMS, data: [rooms] });
    sections.push({ title: CATEGORY_PEOPLE, data: [users] });
    let roomsCollection = arrToObject(rooms, 'id');
    let usersCollection = arrToObject(users, 'id');
    this.setState({
      sections,
      roomsOrder,
      usersOrder,
      rooms: !_.isEqual(roomsCollection, this.state.rooms) ? roomsCollection : this.state.rooms,
      users: !_.isEqual(usersCollection, this.state.users) ? usersCollection : this.state.users
    });
  };

In this function you can see that there are arrays that manage the order (roomsOrder/usersOrder)separate from the actual list data (rooms/users). The rooms order is just basically an array of the ids tied to each item, in the order they came in from my API due to an external order update (in my case, the web version of this app could affect re-ordering). You can see here I only re-apply the actual rooms/users list data if the new list data is not equal to the current data:

rooms: !_.isEqual(roomsCollection, this.state.rooms) ? roomsCollection : this.state.rooms,
users: !_.isEqual(usersCollection, this.state.users) ? usersCollection : this.state.users
jeffreybello commented 5 years ago

HI @embpdaniel

I know this is an old thread but I wanted to know if its possible to do this scenario on this plugin.

I have these

Day 1

Day 2

embpdaniel commented 5 years ago

Hey @jeffreybello I didn't have this need, and in the code I wrote you can see I created two separate sortable lists. Using that set up it wouldn't be possible to transfer one item from one list into the other.

Have you tried maybe just one single sortable list where your categories (Day 1, Day2) are regular items but just simply render differently to look like category headings? This way I think you would be able to transfer items between "categories" since you are dealing with a single sortable list.

jeffreybello commented 5 years ago

@embpdaniel hey that's actually a brilliant and genius idea.

Sure I will try that.

SwikarBhattarai commented 3 months ago

@jeffreybello Hello Brother! Could you share your code if you implemented that.