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

Change Pinch Gesture Handler Behavior to transition to END state after one finger is taken off screen and one left #1214

Closed
terrysahaidak opened this issue Oct 19, 2020 · 14 comments · May be fixed by #1391
Assignees
Labels

Comments

@terrysahaidak
Copy link

Description

After testing PinchGestureHandler in react-native-gallery-toolkit I found that on the real device Pinch gesture is active even though we have only a single finger on the screen. This breaks my focal point logic which is working as expected on Android.

Screenshots

As soon as I release one finger - it jumps because now the focal point is not the point between fingers, but that single finger location.

But it should just transition to state "END" instead.

RPReplay_Final1603130602

Steps To Reproduce

  1. Run pinch example
  2. Add log to onGestureEvent of PinchHandler
onGestureEvent={(evt) => console.log(evt.nativeEvent.numberOfFingers)}
  1. Do the pinch then release one finger and move it
  2. See that it fires even with 1 finger pressed after wi

Expected behavior

The gesture should be in the state "end" as soon as the user releases one finger – just like on Android

Actual behavior

The gesture remains active even when the user released one finger

Snack or minimal code example

Run the example app

Package versions

  • React: 16.13.1
  • React Native: 0.63.3
  • React Native Gesture Handler: 1.8.0
@terrysahaidak
Copy link
Author

Tested UIPinchGestureRecognizer and yeah, it stays active – that's the behavior of iOS here.

@terrysahaidak
Copy link
Author

As I workaround for now I do:

When State === ACTIVE and numberOfFingers !== 2
Call disablePinch from this snippet:

const [pinchEnabled, setPinchEnabledState] = useState(true);
useEffect(() => {
  if (!pinchEnabled) {
    setPinchEnabledState(true);
  }
}, [pinchEnabled]);
const disablePinch = useCallback(() => {
  setPinchEnabledState(false);
}, []);

When you need to pass the pinchEnabled to Pinch's enabled prop

In that case, we disable the pinch gesture handler as soon as the user releases one finger.
This triggers the Pinch handler to transition into the CANCELED state – just run whatever logic you had on the END state alongside with that CANCELED.

That workaround did the trick for me. Hope it will help someone else.

But yeah, would be cool to have this case handled in the native.

@jkadamczyk jkadamczyk added the Platform: Android This issue is specific to Android label Oct 20, 2020
@jkadamczyk jkadamczyk self-assigned this Oct 20, 2020
@jkadamczyk jkadamczyk added Platform: iOS This issue is specific to iOS and removed Platform: Android This issue is specific to Android labels Oct 20, 2020
@jkadamczyk
Copy link
Contributor

@terrysahaidak I am happy you found a solution and noticed it is a native behavior.
If I recall correctly, a long time ago I was implementing a similar functionality (zooming and panning photos) and I remember that I had to use simultaneous handlers of pinch + pan to avoid this rapid movement of a focal point. to the finger that is left on the screen.

@jkadamczyk
Copy link
Contributor

I will try to experiment around it, but will probably close the issue anyway, as Gesture Handler was always about exposing native gesture recognizers and not making them the same on both platforms.

@terrysahaidak
Copy link
Author

The only real problem I found is that you probably would want to run some animation as soon as user leaves that finger, and you would do it on "END", but you can't because the "END" won't ever be called here.

The real-world case for this: after you zoom-out and scale < 1 - animate scale back to 1. This behavior exists, for example, in iOS Photos app, but they don't animate, but just set the scale back to 1.

I guess they do the same trick here.

My workaround is far from perfect because I trigger rerender of that view twice in the row which isn't great.

I think the ability to at least set enabled using setNativeProps would make it for me.
Also, it would allow us to change that from Reanimated Worklets as well.

@jkadamczyk
Copy link
Contributor

@terrysahaidak I will mark this issue as a feature request to alter how the pinch gesture handler behaves and speak to the rest of the team whether we want to change that.

@jkadamczyk jkadamczyk changed the title Pinch gesture handler should transition to the END state when two fingers aren't pressed anymore Change Pinch Gesture Handler Behavior to transition to END state after one finger is taken off screen and one left Oct 21, 2020
@terrysahaidak
Copy link
Author

Sounds good, thank you.

@nhannah
Copy link

nhannah commented Nov 10, 2020

I will try to experiment around it, but will probably close the issue anyway, as Gesture Handler was always about exposing native gesture recognizers and not making them the same on both platforms.

I have worked with this quite a bit and have the opposite issue, Android failing on 1 finger and not starting the pan when failed. You need to keep track of the focal points and fingers to adjust back and forth to removing and adding fingers. Here is some code to help you out....

const onPinch = useAnimatedGestureHandler<PinchGestureHandlerGestureEvent>(
    {
      onActive: (evt) => {
        /**
         * The scale is clamped to a minimum of 1 and maximum of 8 for aesthetics.
         * We use the clamped value to determine a local event scale so the focal
         * point does not become out of sync with the actual photo scaling, e.g.
         * evt.scale is 20 but scale is 8, using evt.scale for offset will put the
         * photo and calculations out of sync
         */
        scale.value = clamp(offsetScale.value * evt.scale, 1, 8);
        const localEvtScale = scale.value / offsetScale.value;

        /**
         * We calculate the adjusted focal point on the photo using the events
         * focal position on the screen, screen size, and current photo offset
         */
        adjustedFocalX.value = evt.focalX - (halfScreenWidth - offsetX.value);
        adjustedFocalY.value = evt.focalY - (halfScreenHeight + offsetY.value);

        /**
         * If the number of fingers on the screen changes, the position of the
         * focal point will change and this needs to be accounted for, e.g. if
         * two fingers are on the screen the focal is between them, but if one is
         * then removed the focal is now at the remaining fingers touch position.
         * If this happens without accounting for the change the image will jump
         * around, we keep track of the previous two finger focal to adjust for this
         * change in a reduction from two fingers to one, then if another finger
         * is added again we adjust the origin to account for the difference between
         * the original two finger touch and the new two finger touch position.
         */
        if (numberOfPinchFingers.value !== evt.numberOfPointers) {
          numberOfPinchFingers.value = evt.numberOfPointers;
          if (evt.numberOfPointers === 1) {
            focalOffsetX.value = oldFocalX.value - adjustedFocalX.value;
            focalOffsetY.value = oldFocalY.value - adjustedFocalY.value;
          } else if (numberOfPinchFingers.value > 1) {
            originX.value =
              originX.value -
              (oldFocalX.value / localEvtScale -
                adjustedFocalX.value / localEvtScale);
            originY.value =
              originY.value -
              (oldFocalY.value / localEvtScale -
                adjustedFocalY.value / localEvtScale);
          }
        }

        /**
         * If pinch handler has been activated via two fingers then the fingers
         * reduced to one we keep track of the old focal using the focal offset
         * from when the number of fingers was two. We then translate the photo
         * taking into account the offset, focal, focal offset, origin, and scale.
         */
        if (numberOfPinchFingers.value === 1) {
          oldFocalX.value = adjustedFocalX.value + focalOffsetX.value;
          oldFocalY.value = adjustedFocalY.value + focalOffsetY.value;
          translateX.value =
            offsetX.value - oldFocalX.value + localEvtScale * originX.value;
          translateY.value =
            offsetY.value + oldFocalY.value - localEvtScale * originY.value;

          /**
           * If the number of fingers in the gesture is greater than one the
           * adjusted focal point is saved as the old focal and the photo is
           * translated taking into account the offset, focal, origin, and scale.
           */
        } else if (numberOfPinchFingers.value > 1) {
          oldFocalX.value = adjustedFocalX.value;
          oldFocalY.value = adjustedFocalY.value;
          translateX.value =
            offsetX.value -
            adjustedFocalX.value +
            localEvtScale * originX.value;
          translateY.value =
            offsetY.value +
            adjustedFocalY.value -
            localEvtScale * originY.value;
        }
      },
      onFinish: () => {
        /**
         * When the pinch is finished if the scale is less than 1 return the
         * photo to center, if the photo is inside the edges of the screen
         * return the photo to line up with the edges, otherwise leave the
         * photo in its current position
         */
        translateX.value =
          scale.value < 1
            ? withTiming(0)
            : translateX.value > halfScreenWidth * (scale.value - 1)
            ? withTiming(halfScreenWidth * (scale.value - 1))
            : translateX.value < -halfScreenWidth * (scale.value - 1)
            ? withTiming(-halfScreenWidth * (scale.value - 1))
            : translateX.value;

        /**
         * When the pinch is finished if the height is less than the screen
         * height return the photo to center, if the photo is inside the
         * edges of the screen return the photo to line up with the edges,
         * otherwise leave the photo in its current position
         */
        translateY.value =
          currentImageHeight * scale.value < screenHeight
            ? withTiming(0)
            : translateY.value >
              (currentImageHeight / 2) * scale.value - screenHeight / 2
            ? withTiming(
                (currentImageHeight / 2) * scale.value - screenHeight / 2,
              )
            : translateY.value <
              (-currentImageHeight / 2) * scale.value + screenHeight / 2
            ? withTiming(
                (-currentImageHeight / 2) * scale.value + screenHeight / 2,
              )
            : translateY.value;

        /**
         * If the scale has been reduced below one, i.e. zoomed out, translate
         * the zoom back to one
         */
        offsetScale.value = scale.value < 1 ? 1 : scale.value;
        scale.value = scale.value < 1 ? withTiming(1) : scale.value;

        resetTouchValues();
      },
      onStart: (evt) => {
        if (evt.numberOfPointers > 1) { // This # pointers check is WIP for android support, not needed in current iOS version
          /**
           * Cancel any previous motion animation on translations when a touch
           * begins to interrupt the animation and take over the position handling
           */
          cancelAnimation(translateX);
          cancelAnimation(translateY);
          cancelAnimation(scale);

          /**
           * Set pinch to true to stop all pan gesture interactions
           */
          isPinch.value = true;

          /**
           * Reset isSwiping as now the pan gesture handler is no longer running
           */
          isSwiping.value = IsSwiping.UNDETERMINED;

          /**
           * Set initial values for pinch gesture interaction handler
           */
          numberOfPinchFingers.value = evt.numberOfPointers;
          offsetX.value = translateX.value;
          offsetY.value = translateY.value;
          adjustedFocalX.value = evt.focalX - (halfScreenWidth - offsetX.value);
          adjustedFocalY.value =
            evt.focalY - (halfScreenHeight + offsetY.value);
          originX.value = adjustedFocalX.value;
          originY.value = adjustedFocalY.value;
          offsetScale.value = scale.value;
        }
      },
    },
    [currentImageHeight],
  );

@nhannah
Copy link

nhannah commented Nov 10, 2020

@terrysahaidak please consider the opposite as currently smooth 1->2->1->2 finger movement on iOS seems to function better than Android. I have just spent a good amount of time on this issue and can give a bit more insight after looking at the Android implementation. Currently Android gets a start event with 1 finger pressing, this start event is missing info for focal, etc. Then until a second finger is applied no active events are fired, once a second finger is applied active events come in again until less than 2 fingers remain, GestureHandler then fires the end event, but as a finger still remains I believe MotionEvents from ScaleGestureDetector still arrive and are discarded. This means that at best with a Pan and Pinch Handler on Android you can pan, then pinch, then pan, i.e. 1->2->1 fingers. You cannot return to 2 finger use. I have implemented this using reanimated 2 alongside the iOS version where the pinch handler takes over indefinitely after it starts and it seems to me as the pinch handler on iOS is almost a pass through to UIPinchGestureRecognizer it is the preferable way; but either way matching the interaction would be the key preference here.

@mrousavy
Copy link
Contributor

Looks like my issue (#1225) is related to this one 🤔

@iqqmuT
Copy link

iqqmuT commented Feb 27, 2021

Too bad UIPinchGestureRecognizer works that way and I really wonder why. It would make more sense if pinch gesture ended when another finger was taken off screen. Workarounds just make code much more complicated.

@nhannah
Copy link

nhannah commented Mar 6, 2021

@iqqmuT You can take a look at my implementation of pinch to zoom here: https://github.com/GetStream/stream-chat-react-native/blob/92f891e42ec2c881d357ffc1a759fc8c26edf727/src/components/ImageGallery/ImageGallery.tsx . Overall for a smooth experience the iOS functionality makes more sense and is a 1:1 representation of the native handler as I noted previously. You can easily deal with the change in the number of touches detected, but with Android ending the gesture tracking you are not given a choice of how you want to deal with one finger being removed. Therefore the iOS implementation adds flexibility and utility.

@iqqmuT
Copy link

iqqmuT commented Mar 7, 2021

@nhannah Thanks for the hint! Unfortunately I can't use reanimated library, because it requires Hermes. I'm using another library that is incompatible with Hermes.

@kmagiera
Copy link
Member

For this scenario I'd suggest looking at numberOfPointers prop of an event. Apparently the focal point does not need to transition gradually also in cases when you add more fingers (not just when you lift one of two fingers). If you have code that relies on focal point to only follow the movement this logic needs to be adapted to those cases (e.g. ignore events that have numberOfPointers != 2

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

Successfully merging a pull request may close this issue.

7 participants