Skip to content
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

Support multi-touch behaviour with panning and scrolling #2622

Open
matis-dk opened this issue Oct 7, 2023 · 4 comments
Open

Support multi-touch behaviour with panning and scrolling #2622

matis-dk opened this issue Oct 7, 2023 · 4 comments
Assignees
Labels
Feature request Platform: iOS This issue is specific to iOS Platform: Web Repro provided A reproduction with a snack or repo is provided

Comments

@matis-dk
Copy link

matis-dk commented Oct 7, 2023

Description

I’m trying to implement something similar to the gesture behaviour on the default list view on iOS. Eg. the “Reminders” app inside iOS.

It consist of a scrollable view with some list item's inside. When you long-press a list item, it lets you rearrange where the item belong. At the surface, this implementation seems quite simple - a ScrollView wrapping around multiple ListItem component's that is using the Gesture.Pan().activateAfterLongPress(250).

The code could look something like this
import { useRef } from "react";
import { Dimensions, View } from "react-native";
import {
  Gesture,
  GestureDetector,
  ScrollView,
} from "react-native-gesture-handler";
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSpring,
} from "react-native-reanimated";
import { Text } from "../restyled/Text";

const items = [
  { id: "id_John", name: "John" },
  { id: "id_Jane", name: "Jane" },
  { id: "id_Jack", name: "Jack" },
  { id: "id_Jill", name: "Jill" },
  { id: "id_Joe", name: "Joe" },
  { id: "id_Jim", name: "Jim" },
  { id: "id_Joy", name: "Joy" },
];

const listItemHeight = 75;
const listItemWidth = Dimensions.get("window").width;

type Position = { x: number; y: number };

export function List() {
  return (
    <ScrollView style={{ flex: 1, gap: 12 }}>
        {items.map((item, index) => (
          <ListItem key={item.id} id={item.id} />
        ))}
    </ScrollView>
  );
}

type ListItemProps = {
  id: string;
};

function ListItem(props: ListItemProps) {
  const { id } = props;

  const sharedSelectedItem = useSharedValue<string | null>(null);
  const sharedSelectedPosStart = useRef(
    useSharedValue<Position>({
      x: 0,
      y: 0,
    })
  ).current;
  const sharedSelectedPos = useRef(
    useSharedValue<Position>({
      x: 0,
      y: 0,
    })
  ).current;

  const panGesture = Gesture.Pan()
    .activateAfterLongPress(250)
    .onStart((e) => {
      sharedSelectedItem.value = id;
      sharedSelectedPosStart.value = { x: e.absoluteX, y: e.absoluteY };
    })
    .onUpdate((e) => {
      sharedSelectedPos.value = {
        y: e.absoluteY - sharedSelectedPosStart.value.y,
        x: e.absoluteX - sharedSelectedPosStart.value.x,
      };
    })
    .onFinalize((e) => {
      sharedSelectedItem.value = null;
      sharedSelectedPos.value = {
        x: withSpring(0),
        y: withSpring(0),
      };
    });

  const animatedStyle = useAnimatedStyle(() => {
    const active = sharedSelectedItem.value === id;
    return {
      backgroundColor: active ? "blue" : "gray",
      opacity: active ? 1 : 0.5,
      transform: [
        {
          translateX: sharedSelectedPos.value.x,
        },
        {
          translateY: sharedSelectedPos.value.y,
        },
      ],
    };
  });

  return (
    <GestureDetector gesture={panGesture}>
      <Animated.View
        key={props.id}
        style={[
          {
            height: listItemHeight,
            width: listItemWidth,
            justifyContent: "center",
          },
          animatedStyle,
        ]}
      >
        <Text style={{ textAlign: "center" }}>{props.id}</Text>
      </Animated.View>
    </GestureDetector>
  );
}

The problem arise when the scrollable list is expanding beyond the device height dimensions. Because if you would like to drag a ListItem to the bottom of the list, then you need to be able to support scrolling while panning (dragging the item around). To support this, iOS implement 2 different scroll behaviors.

  1. Hot areas in top and bottom of the screen, that imperatively scroll at a constant pace.
  2. Support for scroll with a secondary finger, while the panning is active with the primary finger.

I think that bullet 1. is possible by keeping track of the fingers absolute position on the screen, and activate scroll imperatively, when the primary finger is entering hot areas in the top or bottom of the screen.

But bullet 2. doesn't seem to be possible by the primitive provider by RNGH currently.
By default, ScrollView's seems to be dismissed when a pan-gesture is active. The simultaneousHandlers prop allow the ScrollView gesture, and Gesture.Pan, to be active simultaneously - but this let the primary finger, pan and scroll at the same time.

What seems to be the crux of the problem, is that when a Gesture.Pan is active, RNGH is disabling ScrollView gestures globally, and not locally to the current gesture in action. If theGesture.Pan instead recognized and ignored the scrollview events, for the local pan-gesture in action, it would allow the secondary finger to scroll. What seems to be missing is a way to distingquish between disabling and ignoring ScrollView events for a given gesture.

The multi-touch behavior combining pan with the primary finger, and scrolling with secondary finger, is used multiple places inside iOS. E.g when.

  • rearranging widgets on widget screen (vertical scroll)
  • rearranging icons on one of the homescreen (horizontal scroll)
  • rearranging items on a native list view (vertical scroll)

I noticed that Gesture.Native is able to wrap and listen to events on the ScrollView, but I'm unable to figure out how this can be composed with behavior im describing above.

Steps to reproduce

The code could look something like this
import { useRef } from "react";
import { Dimensions, View } from "react-native";
import {
  Gesture,
  GestureDetector,
  ScrollView,
} from "react-native-gesture-handler";
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSpring,
} from "react-native-reanimated";
import { Text } from "../restyled/Text";

const items = [
  { id: "id_John", name: "John" },
  { id: "id_Jane", name: "Jane" },
  { id: "id_Jack", name: "Jack" },
  { id: "id_Jill", name: "Jill" },
  { id: "id_Joe", name: "Joe" },
  { id: "id_Jim", name: "Jim" },
  { id: "id_Joy", name: "Joy" },
];

const listItemHeight = 75;
const listItemWidth = Dimensions.get("window").width;

type Position = { x: number; y: number };

export function List() {
  return (
    <ScrollView contentContainerStyle={{ flex: 1 }}>
      <View style={{ gap: 12 }}>
        {items.map((item, index) => (
          <ListItem key={item.id} id={item.id} />
        ))}
      </View>
    </ScrollView>
  );
}

type ListItemProps = {
  id: string;
};

function ListItem(props: ListItemProps) {
  const { id } = props;

  const sharedSelectedItem = useSharedValue<string | null>(null);
  const sharedSelectedPosStart = useRef(
    useSharedValue<Position>({
      x: 0,
      y: 0,
    })
  ).current;
  const sharedSelectedPos = useRef(
    useSharedValue<Position>({
      x: 0,
      y: 0,
    })
  ).current;

  const panGesture = Gesture.Pan()
    .activateAfterLongPress(250)
    .onStart((e) => {
      sharedSelectedItem.value = id;
      sharedSelectedPosStart.value = { x: e.absoluteX, y: e.absoluteY };
    })
    .onUpdate((e) => {
      sharedSelectedPos.value = {
        y: e.absoluteY - sharedSelectedPosStart.value.y,
        x: e.absoluteX - sharedSelectedPosStart.value.x,
      };
    })
    .onFinalize((e) => {
      sharedSelectedItem.value = null;
      sharedSelectedPos.value = {
        x: withSpring(0),
        y: withSpring(0),
      };
    });

  const animatedStyle = useAnimatedStyle(() => {
    const active = sharedSelectedItem.value === id;
    return {
      backgroundColor: active ? "blue" : "gray",
      opacity: active ? 1 : 0.5,
      transform: [
        {
          translateX: sharedSelectedPos.value.x,
        },
        {
          translateY: sharedSelectedPos.value.y,
        },
      ],
    };
  });

  return (
    <GestureDetector gesture={panGesture}>
      <Animated.View
        key={props.id}
        style={[
          {
            height: listItemHeight,
            width: listItemWidth,
            justifyContent: "center",
          },
          animatedStyle,
        ]}
      >
        <Text style={{ textAlign: "center" }}>{props.id}</Text>
      </Animated.View>
    </GestureDetector>
  );
}

Snack or a link to a repository

https://snack.expo.dev/@matis/99d863

Gesture Handler version

2.12.0

React Native version

0.72.4

Platforms

Android, iOS

JavaScript runtime

None

Workflow

None

Architecture

None

Build type

None

Device

None

Device model

No response

Acknowledgements

Yes

@github-actions github-actions bot added Platform: Android This issue is specific to Android Platform: iOS This issue is specific to iOS Repro provided A reproduction with a snack or repo is provided labels Oct 7, 2023
@matis-dk
Copy link
Author

matis-dk commented Dec 24, 2023

Demo showcasing scroll and panning simultaneously.

Dragging a list item from iOS's "Reminders" app:
https://github.com/software-mansion/react-native-gesture-handler/assets/32526593/d42a6357-e316-4241-8cfd-4b6e2a65ee51

Dragging a iOS widget between the different homescreen:
https://github.com/software-mansion/react-native-gesture-handler/assets/32526593/3cffb182-9b4c-414c-9e61-af83db2db570

@matis-dk
Copy link
Author

@j-piasecki can you by any chance confirm that this is in fact currently not possible with the primitives provided by RNGH?

@zebriot
Copy link

zebriot commented Aug 8, 2024

Here is a snippet i used with to handle simulatneously multiple gestures with native elements, the key is to have the native element as direct child to your gesture detector

You can do something like this,

  const nativeGesture = Gesture.Native();
  const composedGestures = Gesture.Simultaneous(myPanGesture, nativeGesture);
  const ScrollComponent = (props: any) => {
    return (
     <GestureDetector gesture={composedGestures}>
      <ScrollView {...props} />
      </GestureDetector>
    );
  };

  return (
        <Flashlist
          renderScrollComponent={ScrollComponent}
            {...otherProps}
        />
  );

This will handle both gestures simultaneously, I am just using translationX in my myPanGesture gesture so i havent tried yet with others.

@latekvo
Copy link
Contributor

latekvo commented Sep 4, 2024

Hi @matis-dk

The end result you described is as far as i know only possible to achieve on Android phones for now.

The code snippet sent by @zebriot is a good example of how you can achieve these results, the key is to wrap your scroll component with Gesture.Native().

Here's another example which works well on Android:

Collapsed code
import React from 'react';
import { StyleSheet, Text, View, ScrollView } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSpring,
} from 'react-native-reanimated';

export default function EmptyExample() {
  const panActive = useSharedValue(false);
  const position = useSharedValue(0);
  const panPreviousPosition = useSharedValue(0);
  const scrollPreviousPosition = useSharedValue(-1);

  const scroll = Gesture.Native()
    .onTouchesMove((event) => {
      ('worklet');
      if (!panActive.value) {
        return;
      }

      const scrollPosition = event.allTouches[0].absoluteY;

      if (scrollPreviousPosition.value === -1) {
        scrollPreviousPosition.value = scrollPosition;
        return;
      }

      const delta = scrollPosition - scrollPreviousPosition.value;
      scrollPreviousPosition.value = scrollPosition;

      position.value = position.value - delta;

      // console.log('delta', delta);
      // console.log('position', position.value);
    })
    .onFinalize(() => {
      scrollPreviousPosition.value = -1;
    });

  const pan = Gesture.Pan()
    .activateAfterLongPress(250)
    .onStart(() => {
      panActive.value = true;
      position.value = 0;
    })
    .onUpdate((e) => {
      const delta = e.translationY - panPreviousPosition.value;

      position.value += delta;

      panPreviousPosition.value = e.translationY;
    })
    .onFinalize(() => {
      position.value = withSpring(0);
      panActive.value = false;
      panPreviousPosition.value = 0;
      scrollPreviousPosition.value = -1;
    });

  const elementPadding = 21;
  const elementFiller = (
    <>
      {new Array(elementPadding).fill(1).map(() => (
        <View style={styles.box} key={Math.random()}>
          <Text>Hello World!</Text>
        </View>
      ))}
    </>
  );

  const animation = useAnimatedStyle(() => ({
    ...styles.highlight,
    transform: [
      {
        translateY: position.value ?? 0,
      },
    ],
  }));

  return (
    <GestureDetector gesture={scroll}>
      <ScrollView style={styles.container}>
        {elementFiller}
        <GestureDetector gesture={pan}>
          <Animated.View style={animation}>
            <Text>Hello World!</Text>
          </Animated.View>
        </GestureDetector>
        {elementFiller}
      </ScrollView>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#F5FCFF',
  },
  box: {
    padding: 40,
    backgroundColor: 'navy',
    justifyContent: 'center',
  },
  highlight: {
    padding: 40,
    backgroundColor: 'tomato',
    color: 'white',
    zIndex: 99,
  },
});

But please keep in mind, as this feature works only on Android.


update

as of the following PR: #3095, the Gesture.Pan() will require .simultaneousWithExternalGesture(scroll) to be added to it.

@latekvo latekvo added Platform: Web and removed Platform: Android This issue is specific to Android labels Sep 4, 2024
latekvo added a commit that referenced this issue Sep 26, 2024
…ready being active (#3095)

## Description

This PR fixes invalid activation of gestures nested inside other
gestures, like `Pan` gesture nested inside `Native` gesture attached to
`ScrollView`

Gestures nested inside native elements such as `ScrollView` used to be
able to steal pointers from their already active parents.

That is no longer possible, already active parents cannot have their
active pointers stolen.

Related to #2622

## Test plan

- use the attached code in place of `EmptyExample.tsx`
- start scrolling the `ScrollView`
- while scrolling the `ScrollView`, drag the `Pan` gesture
- see how before this PR, the `Pan` gesture activated, and with this PR
it doesn't anymore

## Notes

- tested this PR on each of the available examples, found no breaking
changes
- nested gestures may still be run simultaneously if it's explicitly
stated using `Gesture.Simultaneous()` or
`simultaneousWithExternalGesture()`


## Code

<details>

<summary>
Collapsed code
</summary>

```js
import React from 'react';
import { StyleSheet, Text, View, ScrollView } from 'react-native';
import {
  Gesture,
  GestureDetector,
  GestureUpdateEvent,
  PanGestureHandlerEventPayload,
} from 'react-native-gesture-handler';
import Animated, {
  SharedValue,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
} from 'react-native-reanimated';

export default function EmptyExample() {
  const firstExternalPosition = useSharedValue<{ x: number; y: number }>({
    x: 0,
    y: 0,
  });

  const secondExternalPosition = useSharedValue<{ x: number; y: number }>({
    x: 0,
    y: 0,
  });

  const nestedPosition = useSharedValue<{ x: number; y: number }>({
    x: 0,
    y: 0,
  });

  const setter = (
    position: SharedValue<{
      x: number;
      y: number;
    }>
  ) => {
    return (event: GestureUpdateEvent<PanGestureHandlerEventPayload>) => {
      'worklet';
      position.value = {
        x: event.translationX,
        y: event.translationY,
      };
    };
  };

  const resetter = (
    position: SharedValue<{
      x: number;
      y: number;
    }>
  ) => {
    return () => {
      'worklet';
      position.value = {
        x: withSpring(0),
        y: withSpring(0),
      };
    };
  };

  const scrollGesture = Gesture.Native();

  const firstExternalPan = Gesture.Pan()
    .onUpdate(setter(firstExternalPosition))
    .onFinalize(resetter(firstExternalPosition));

  const secondExternalPan = Gesture.Pan()
    .onUpdate(setter(secondExternalPosition))
    .onFinalize(resetter(secondExternalPosition));

  const nestedPan = Gesture.Pan()
    // .simultaneousWithExternalGesture(scrollGesture)
    .onUpdate(setter(nestedPosition))
    .onFinalize(resetter(nestedPosition));

  const firstExternalAnimation = useAnimatedStyle(() => {
    return {
      ...styles.box,
      transform: [
        { translateX: firstExternalPosition.value.x },
        { translateY: firstExternalPosition.value.y },
      ],
    };
  });

  const secondExternalAnimation = useAnimatedStyle(() => {
    return {
      ...styles.box,
      transform: [
        { translateX: secondExternalPosition.value.x },
        { translateY: secondExternalPosition.value.y },
      ],
    };
  });

  const nestedAnimation = useAnimatedStyle(() => {
    return {
      ...styles.box,
      transform: [
        { translateX: nestedPosition.value.x },
        { translateY: nestedPosition.value.y },
      ],
    };
  });

  return (
    <View style={styles.container}>
      <View style={styles.externalContainer}>
        <GestureDetector gesture={firstExternalPan}>
          <Animated.View style={firstExternalAnimation}>
            <Text>
              Square showcasing 2 disconnected gestures can be moved
              independantly regardless of changes in this PR, and regardless if
              one of them is nested inside a native handler.
            </Text>
          </Animated.View>
        </GestureDetector>
        <GestureDetector gesture={secondExternalPan}>
          <Animated.View style={secondExternalAnimation}>
            <Text>
              Square showcasing 2 disconnected gestures can be moved
              independantly regardless of changes in this PR, and regardless if
              one of them is nested inside a native handler.
            </Text>
          </Animated.View>
        </GestureDetector>
      </View>

      <View>
        <GestureDetector gesture={scrollGesture}>
          <ScrollView style={styles.list}>
            <GestureDetector gesture={nestedPan}>
              <Animated.View style={nestedAnimation}>
                <Text>GH Gesture</Text>
              </Animated.View>
            </GestureDetector>

            {new Array(20)
              .fill(1)
              .map((value, index) => value * index)
              .map((value) => (
                <View key={value} style={styles.element}>
                  <Text>Entry no. {value}</Text>
                </View>
              ))}
          </ScrollView>
        </GestureDetector>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#F5FCFF',
    gap: 20,
  },
  externalContainer: {
    flexDirection: 'row',
    gap: 20,
    marginTop: 300,
  },
  box: {
    position: 'relative',
    backgroundColor: 'tomato',
    width: 200,
    height: 200,
  },
  list: {
    width: 200,
    backgroundColor: 'plum',
  },
  element: {
    margin: 1,
    height: 40,
    backgroundColor: 'orange',
  },
});

```

</details>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature request Platform: iOS This issue is specific to iOS Platform: Web Repro provided A reproduction with a snack or repo is provided
Projects
None yet
Development

No branches or pull requests

4 participants