Pan Responder only fires once when updating parent component's state?

Viewed 532

I'm using React Native's Pan Responder. I need to update some state in a parent when a Pan Responder in it's children is dragged.

The complex bit is that I also need these children to insert their own element into the draggable area.

For context, here is simpler example that works fine https://snack.expo.io/@jamesweblondon/drag-items

import * as React from 'react';
import { useRef, useState } from 'react';
import { Text, View, PanResponder } from 'react-native';

const items = ['1', '2', '3'];
const ITEM_HEIGHT = 100;

const Parent = () => {
  const [y, setY] = useState(0);
  const [index, setIndex] = useState(null);
  return (
    <View style={{ marginTop: 50 }}>
      <Text>Index: {index}</Text>
      <Text>Y: {y}</Text>
      <View
        style={{ height: ITEM_HEIGHT * items.length, backgroundColor: 'gold' }}>
        {items.map((item, itemIndex) => {
          const isBeingDragged = itemIndex === index;
          const top =
            isBeingDragged
              ? (ITEM_HEIGHT * itemIndex) + y
              : (ITEM_HEIGHT * itemIndex);
          return (
            <View
              style={{
                top,
                width: '100%',
                position: 'absolute',
                zIndex: isBeingDragged ? 1 : 0
              }}
              key={itemIndex}>
              <Child
                index={itemIndex}
                setIndex={setIndex}
                setY={setY}
                item={item}
              />
            </View>
          );
        })}
      </View>
    </View>
  );
};

const Child = ({ index, setIndex, setY, item }) => {
  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: (evt, gestureState) => true,
      onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
      onMoveShouldSetPanResponder: (evt, gestureState) => true,
      onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
      onPanResponderGrant: (evt, gestureState) => {
        setIndex(index);
      },
      onPanResponderMove: (evt, gestureState) => {
        setY(gestureState.dy);
      },
      onPanResponderTerminationRequest: (evt, gestureState) => true,
      onPanResponderRelease: (evt, gestureState) => {
        setY(0);
        setIndex(null);
      },
      onPanResponderTerminate: (evt, gestureState) => {},
      onShouldBlockNativeResponder: (evt, gestureState) => true,
    })
  ).current;

  return (
    <View
      style={{
        flexDirection: 'row',
        justifyContent: 'space-between',
        backgroundColor: 'tomato',
        padding: 10,
        borderBottomColor: 'black',
        borderBottomWidth: 1,
        height: ITEM_HEIGHT,
      }}>
      <View
        {...panResponder.panHandlers}
        style={{ background: 'grey', height: '100%', width: 40 }}
      />
      <Text>Child {item}</Text>
    </View>
  );
};

export default Parent;

enter image description here

Here is my full example: https://snack.expo.io/@jamesweblondon/drag2

import * as React from 'react';
import { useRef, useState } from 'react';
import { Text, View, PanResponder } from 'react-native';

const CHILD_A = 'CHILD_A';
const CHILD_B = 'CHILD_B';
const CHILD_A_HEIGHT = 100;
const CHILD_B_HEIGHT = 200;

const items = [
  { type: CHILD_A, text: '1' },
  { type: CHILD_B, text: '2' },
  { type: CHILD_A, text: '3' },
];

const Parent = () => {
  const [y, setY] = useState(0);
  const [index, setIndex] = useState(null);

  const heights = items.map((item) =>
    item.type === CHILD_A ? CHILD_A_HEIGHT : CHILD_B_HEIGHT
  );

  let heightsSum = 0;
  const heightsCumulative = heights.map(
    (elem) => (heightsSum = heightsSum + elem)
  );

  return (
    <View style={{ marginTop: 50 }}>
      <Text>Index: {index}</Text>
      <Text>Y: {y}</Text>
      <View style={{ height: heightsSum, backgroundColor: 'gold' }}>
        {items.map((item, itemIndex) => {
          if (item.type === CHILD_A) {
            return (
              <ChildA
                index={itemIndex}
                setIndex={setIndex}
                setY={setY}
                text={item.text}
                DragHandle={(props) => (
                  <DragHandle
                    {...props}
                    index={itemIndex}
                    setIndex={setIndex}
                    setY={setY}
                  />
                )}
              />
            );
          }
          return (
            <ChildB
              index={itemIndex}
              setIndex={setIndex}
              setY={setY}
              text={item.text}
              DragHandle={(props) => (
                <DragHandle
                  {...props}
                  index={itemIndex}
                  setIndex={setIndex}
                  setY={setY}
                />
              )}
            />
          );
        })}
      </View>
    </View>
  );
};

const DragHandle = ({ index, setIndex, setY, children }) => {
  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: (evt, gestureState) => true,
      onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
      onMoveShouldSetPanResponder: (evt, gestureState) => true,
      onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
      onPanResponderGrant: (evt, gestureState) => {
        setIndex(index);
      },
      onPanResponderMove: (evt, gestureState) => {
        console.log(gestureState.dy); // This works when the line below is removed :)
        setY(gestureState); // This does not work :(
      },
      onPanResponderTerminationRequest: (evt, gestureState) => true,
      onPanResponderRelease: (evt, gestureState) => {},
      onPanResponderTerminate: (evt, gestureState) => {},
      onShouldBlockNativeResponder: (evt, gestureState) => true,
    })
  ).current;
  return (
    <View
      {...panResponder.panHandlers}
      style={{ background: 'grey', height: '100%', width: 40, padding: 10 }}>
      {children}
    </View>
  );
};

const ChildA = ({ index, setIndex, setY, text, DragHandle }) => {
  return (
    <View
      style={{
        flexDirection: 'row',
        justifyContent: 'space-between',
        backgroundColor: 'gold',
        padding: 10,
        borderBottomColor: 'black',
        borderBottomWidth: 1,
        height: CHILD_A_HEIGHT,
      }}>
      <DragHandle>
        <View
          style={{ backgroundColor: 'goldenrod', width: '100%', height: '100%' }}
        />
      </DragHandle>
      <Text>Child A: {text}</Text>
    </View>
  );
};

const ChildB = ({ index, setIndex, setY, text, DragHandle }) => {
  return (
    <View
      style={{
        flexDirection: 'row',
        justifyContent: 'space-between',
        backgroundColor: 'green',
        padding: 10,
        borderBottomColor: 'black',
        borderBottomWidth: 1,
        height: CHILD_B_HEIGHT,
      }}>
      <DragHandle>
      <View
          style={{ backgroundColor: 'lawngreen', width: '100%', height: '100%' }}
        />
      </DragHandle>
      <Text>Child B: {text}</Text>
    </View>
  );
};

export default Parent;

When you drag on the DragHandle component these 2 functions fire once initially but don't again as you continue to drag:

console.log(gestureState.dy); // This works when the line below is removed :)
setY(gestureState); // This does not work :(

If I comment out this line: setY(gestureState); // This does not work :(

Then the console.log above it does work. It continues to log many times per second as you drag over it: console.log(gestureState.dy); // This works when the line below is removed :)

I therefore think it's to do with the Pan Responder being recreated when the parent's state changes but I'm not sure how to fix it. I'm also not sure why the simpler example doesn't have this issue.

3 Answers
import React from 'react';
import { useRef, useState } from 'react';
import { Text, View, PanResponder } from 'react-native';

const CHILD_A = 'CHILD_A';
const CHILD_B = 'CHILD_B';
const CHILD_A_HEIGHT = 100;
const CHILD_B_HEIGHT = 200;

const _items = [
  { type: CHILD_A, height: CHILD_A_HEIGHT, text: 'A' },
  { type: CHILD_B, height: CHILD_B_HEIGHT, text: 'B' },
  { type: CHILD_A, height: CHILD_A_HEIGHT, text: 'C' },
];

const Parent = () => {
  const [y, setY] = useState(0);
  const [index, setIndex] = useState(null);
  const [items, setItems] = useState(_items);

  const heights = items.map((item) =>
    item.type === CHILD_A ? CHILD_A_HEIGHT : CHILD_B_HEIGHT
  );

  let heightsSum = 0;
  const heightsCumulative = heights.map(
    (elem) => (heightsSum = heightsSum + elem)
  );

  function setPosition(index, position) {
    setItems(items => {
      if (!items[index].hasOwnProperty('position')) {
        items[index]['position'] = position;
      }
      return items;
    })
  }

  return (
    <View style={{ marginTop: 50 }}>
      <Text>Index: {index}</Text>
      <Text>Y: {y}</Text>
      <View style={{ height: heightsSum, backgroundColor: 'gold' }}>
        {items.map((item, itemIndex) => {
          const isBeingDragged = itemIndex === index;

          const top = isBeingDragged
            ? item.position + y
            : item.position;

          const childProps = {
              top,
              setPosition,
              index: itemIndex,
              setIndex: setIndex,
              setY: setY,
              text: item.text,
              isBeingDragged: isBeingDragged
          };

          if (item.type === CHILD_A) {
            return (
              <ChildA
                 {...childProps}
              />
            );
          }
          return (
            <ChildB
                {...childProps}
            />
          );
        })}
      </View>
    </View>
  );
};

const DragHandle = ({ index, setIndex, setY, children }) => {
  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: (evt, gestureState) => true,
      onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
      onMoveShouldSetPanResponder: (evt, gestureState) => true,
      onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
      onPanResponderGrant: (evt, gestureState) => {
        setIndex(index);
      },
      onPanResponderMove: (evt, gestureState) => {
        setY(gestureState.dy);
      },
      onPanResponderTerminationRequest: (evt, gestureState) => true,
      onPanResponderRelease: (evt, gestureState) => {
        setY(0);
        setIndex(null);
      },
      onPanResponderTerminate: (evt, gestureState) => {},
      onShouldBlockNativeResponder: (evt, gestureState) => true,
    })
  ).current;
  return (
    <View
      {...panResponder.panHandlers}
      style={{ background: 'grey', width: 40, padding: 10 }}>
      {children}
    </View>
  );
};

const ChildA = ({ index, top, isBeingDragged, setPosition, setIndex, setY, text }) => {
  return (
    <View
      style={{
        top,
        width: '100%',
        position: top == undefined ? 'relative' : 'absolute',
        zIndex: isBeingDragged ? 1 : 0,
      }}
      onLayout={(evt) => setPosition(index, evt.nativeEvent.layout.y)}
      key={index}>
      <View
        style={{
          flexDirection: 'row',
          justifyContent: 'space-between',
          backgroundColor: 'gold',
          padding: 10,
          borderBottomColor: 'black',
          borderBottomWidth: 1,
          height: CHILD_A_HEIGHT,
        }}>
        <DragHandle index={index} setIndex={setIndex} setY={setY} text={text}>
          <View
            style={{
              backgroundColor: 'goldenrod',
              width: '100%',
              height: '100%',
            }}
          />
        </DragHandle>
        <Text>Child A: {text}</Text>
      </View>
    </View>
  );
};

const ChildB = ({ index, top, isBeingDragged, setPosition, setIndex, setY, text }) => {
  return (
    <View
      style={{
        top,
        width: '100%',
        position: top == undefined ? 'relative' : 'absolute',
        zIndex: isBeingDragged ? 1 : 0,
      }}
      onLayout={(evt) => setPosition(index, evt.nativeEvent.layout.y)}
      key={index}>
      <View
        style={{
          flexDirection: 'row',
          justifyContent: 'space-between',
          backgroundColor: 'green',
          padding: 10,
          borderBottomColor: 'black',
          borderBottomWidth: 1,
          height: CHILD_B_HEIGHT,
        }}>
        <DragHandle index={index} setIndex={setIndex} setY={setY} text={text}>
          <View
            style={{
              backgroundColor: 'lawngreen',
              width: '100%',
              height: '100%',
            }}
          />
        </DragHandle>
        <Text>Child B: {text}</Text>
      </View>
    </View>
  );
};

export default Parent;

Note: ChildA and ChildB components can also be reduced to single component

Here working example

I fixed your example by rewriting your code. You need to take care to not recreate your pan-responder on every render (which happens on you call setY). which you make your view to switch panhandlers on every render. Actually, setY was rerendering your parent component which causes the render of the child

Demo: https://snack.expo.io/@nomi9995/drag2fix

import * as React from 'react';
import { useRef, useState, useEffect } from 'react';
import { Text, View, PanResponder } from 'react-native';

const CHILD_A = 'CHILD_A';
const CHILD_B = 'CHILD_B';
const CHILD_A_HEIGHT = 100;
const CHILD_B_HEIGHT = 200;

const all_items = [
  { type: CHILD_A, text: 'A', height:CHILD_A_HEIGHT },
  { type: CHILD_B, text: 'B', height:CHILD_B_HEIGHT },
  { type: CHILD_A, text: 'C', height:CHILD_A_HEIGHT },
];

const Parent = () => {
  const [y, setY] = useState(0);
  const [index, setIndex] = useState(null);
    const [items, setItems] = useState(all_items);
    
    const setPosition=(index,y)=>{
        const _items=items;
        _items[index]['position']=y;
        setItems(_items)
    }

  const heights = items.map((item) =>
    item.type === CHILD_A ? CHILD_A_HEIGHT : CHILD_B_HEIGHT
  );

  let heightsSum = 0;
  const heightsCumulative = heights.map(
    (elem) => (heightsSum = heightsSum + elem)
  );

  return (
    <View style={{ marginTop: 50 }}>
      <Text>Index: {index}</Text>
      <Text>Y: {y}</Text>
      <View style={{ height: heightsSum, backgroundColor: 'gold' }}>
        {items.map((item, itemIndex) => {
                    const isBeingDragged = itemIndex === index;
          const top = isBeingDragged ? item.position + y : item.position;
          if (item.type === CHILD_A) {
            return (
                            <Child
                                top={top}
                                isBeingDragged={isBeingDragged}
                                height={item.height}
                                backgroundColor="gold"
                                childBackgroundColor="goldenrod"
                                position={item.position}
                                setPosition={setPosition}
                                index={itemIndex}
                setIndex={setIndex}
                setY={setY}
                                text={item.text}
              />
            );
          }
          return (
                        <Child
                            top={top}
                            isBeingDragged={isBeingDragged}
                            height={item.height}
                            backgroundColor="green"
                            childBackgroundColor="lawngreen"
                            position={item.position}
                            setPosition={setPosition}
              index={itemIndex}
              setIndex={setIndex}
              setY={setY}
              text={item.text}
            />
          );
        })}
      </View>
    </View>
  );
};

const DragHandle = ({ index, setIndex, setY, children }) => {
  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: (evt, gestureState) => true,
      onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
      onMoveShouldSetPanResponder: (evt, gestureState) => true,
      onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
      onPanResponderGrant: (evt, gestureState) => {
        setIndex(index);
      },
      onPanResponderMove: (evt, gestureState) => {
        setY(gestureState.dy); // This does not work :(
      },
      onPanResponderTerminationRequest: (evt, gestureState) => true,
      onPanResponderRelease: (evt, gestureState) => {
                setY(0);
        setIndex(null);
            },
      onPanResponderTerminate: (evt, gestureState) => {},
      onShouldBlockNativeResponder: (evt, gestureState) => true,
    })
  ).current;
  return (
    <View
      {...panResponder.panHandlers}>
      {children}
    </View>
  );
};
 
const Child = ({ top, isBeingDragged, position, height, backgroundColor, childBackgroundColor, setPosition, text2, index, setIndex, setY, text }) => {
    return (
        <View 
        style={{
            top,
            width: '100%',
            position: position?'absolute':'relative',
            zIndex: isBeingDragged ? 1 : 0,
        }}
        key={index}
        onLayout={(e)=>position!==undefined?null:setPosition(index,e.nativeEvent.layout.y)}
        >
        <View
      style={{
        flexDirection: 'row',
        justifyContent: 'space-between',
        backgroundColor: backgroundColor,
        padding: 10,
        borderBottomColor: 'black',
        borderBottomWidth: 1,
        height: height,
            }}>
            <DragHandle index={index} setIndex={setIndex} setY={setY}>
        <View
          style={{ backgroundColor: childBackgroundColor, width: 20, height: '100%' }}
                />
            </DragHandle>
      <Text>Child {text2}: {index+1}</Text>
        </View>
        </View>
  );
};

export default Parent;

enter image description here

Issue is updating state in parent rerender child which is causing the responder to recreate so instead of using useState you can use useReducer with Context api.

import * as React from 'react';
import { useRef, useState, createContext, useContext, useReducer } from 'react';
import { Text, View, PanResponder } from 'react-native';

const items = ['A', 'B', 'A'];

const initialState = {
  y: 0,
};
const store = createContext(initialState);
const { Provider, Consumer } = store;

const StateProvider = ({ children }) => {
  const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case 'UPDATE_Y':
        return { y: action.payload };
      default:
        return state;
    }
  }, initialState);

  return <Provider value={{ state, dispatch }}>{children}</Provider>;
};

const Parent = () => {
  return (
    <StateProvider>
      <View style={{ padding: 30, marginTop: 50 }}>
        <Consumer>{({ state }) => <Text>{state.y}</Text>}</Consumer>
        {items.map((item, index) => {
          if (item === 'A') {
            return (
              <ChildA
                key={index}
                Dragger={(props) => <DragMe {...props} index={index} />}
              />
            );
          }
          return (
            <ChildB
              key={index}
              Dragger={(props) => <DragMe {...props} index={index} />}
            />
          );
        })}
      </View>
    </StateProvider>
  );
};

const DragMe = ({ children }) => {
  const reduxState = useContext(store);
  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: (evt, gestureState) => true,
      onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
      onMoveShouldSetPanResponder: (evt, gestureState) => true,
      onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
      onPanResponderGrant: (evt, gestureState) => {},
      onPanResponderMove: (evt, gestureState) => {
        reduxState.dispatch({
          type: 'UPDATE_Y',
          payload: gestureState.dy,
        });
      },
      onPanResponderTerminationRequest: (evt, gestureState) => true,
      onPanResponderRelease: (evt, gestureState) => {},
      onPanResponderTerminate: (evt, gestureState) => {},
      onShouldBlockNativeResponder: (evt, gestureState) => true,
    })
  ).current;
  return (
    <View
      style={{ backgroundColor: 'grey', width: 100, height: 100, zIndex: 1 }}
      {...panResponder.panHandlers}>
      {children}
    </View>
  );
};

const ChildA = ({ Dragger }) => {
  return (
    <View
      style={{
        flexDirection: 'row',
        justifyContent: 'space-between',
        backgroundColor: 'gold',
      }}>
      <Dragger>
        <Text>Drag Child A</Text>
      </Dragger>
      <Text>Child A</Text>
    </View>
  );
};

const ChildB = ({ Dragger }) => {
  return (
    <View
      style={{
        flexDirection: 'row',
        justifyContent: 'space-between',
        backgroundColor: 'tomato',
      }}>
      <Dragger>
        <Text>Drag Child B</Text>
      </Dragger>
      <Text>Child B</Text>
    </View>
  );
};

export default Parent;

Here working example

Related