Skip to content

Support multi-touch behaviour with panning and scrolling #2622

Description

@matis-dk

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>
  );
}
Details

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

Metadata

Metadata

Assignees

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions