You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[$175] Image attachment - Pinch-to-zoom stops working after reaching maximum zoom #103648
Open a conversation and click the + button in the compose box.
Tap Add attachment
Upload an image
Click on the image to open it
Pinch in with two fingers to zoom in to the maximum level
Try to pinch out with two fingers to zoom out
Double tap to zoom out
Pinch in with two fingers to zoom in
Expected Result:
The user should be able to zoom in and out of the image using the pinch gesture.
Actual Result:
After zooming in to the maximum level, pinching out with two fingers no longer zooms user out.
Once the user zooms out using double tap, pinching gesture becomes completely unresponsive.
Pinch-out does nothing after max zoom (confirmed in code). In usePinchGesture.ts:152-159, onChange checks the oldzoomScale against the bounce range (0.7–30), not the new one. The first event that goes past 30 still gets written. After that, every later event fails the check, so zoomScale freezes until you lift both fingers. Reversing the pinch mid-gesture is ignored.
Pinch stops responding after double-tap (Android only, likely cause). This started after Bump react-native-gesture-handler to 3.3.0 moved RNGH from 2.32.0 to 3.3.0. The PR changed only type imports in our code. RNGH 3.3 changed Android pinch handling in two ways that fit this symptom:
The pinch handler now creates its scale detector only on the first finger's ACTION_DOWN. Our code cancels the handler by toggling .enabled(false) whenever the pointer count isn't 2 (usePinchGesture.ts:145-150). That can leave it with no detector, so it never activates.
When a native view such as the carousel pager claims the touch, RNGH 3.3 now cancels all builder-API handlers. A double-tap reset to scale 1 re-enables pager scrolling, and pinch is not marked simultaneous with the pager gesture.
What changes do you think we should make in order to solve the problem?
In usePinchGestureonChange, compute the new scale and clamp it to the bounce range with the existing clamp util. Then always write the clamped value. Derive currentPinchScale from the clamped value so translation stays in sync.
When numberOfPointers !== 2, return early instead of calling setPinchEnabled(false). Remove the pinchEnabled state and the effect that re-enables it.
Make the pinch gesture simultaneous with the pager's external native gesture, the same way the pan gesture already does in MultiGestureCanvas/index.tsx. This stops the pager from cancelling an active pinch.
Test steps 2 and 3 on an Android device. To confirm that the RNGH upgrade caused part 2, run the repro on 9.5.1-9, which still uses RNGH 2.32.0.
What alternative solutions did you explore? (Optional)
DEFAULT_ZOOM_RANGE is 1–20 and the bounce factors are 0.7/1.5 (constants.ts:17-27). This gives the 0.7–30 window. The same bug blocks pinch-in below 0.7.
onEnd springs the scale back to 20 (usePinchGesture.ts:197-201), so lifting fingers restores the gesture. This matches the "stuck until release" behavior.
The usePinchGesture logic is unchanged since 9.5.0-5. RNGH changed from 2.32.0 in 9.5.1-9 to 3.3.0 in 9.5.2-4 and later, including the reported 9.5.4-3.
Relevant RNGH 3.3 sources: PinchGestureHandler.kt (detector set up only on ACTION_DOWN in UNDETERMINED) and RNGestureHandlerRootHelper.requestDisallowInterceptTouchEvent → cancelAllLegacyHandlers().
Not verified: which of the two RNGH changes triggers part 2. I didn't reproduce this on Android because this run only had a web environment, and web is not affected. Add logcat logging in pinch onBegin/onStart/onFinalize to tell whether the pinch never activates or is cancelled.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/103648) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
changed the title [-]Image attachment - Pinch-to-zoom stops working after reaching maximum zoom[/-][+][$175] Image attachment - Pinch-to-zoom stops working after reaching maximum zoom[/+]on Oct 9, 2026
Pinch-out freezes after reaching max zoom:
In src/components/MultiGestureCanvas/usePinchGesture.ts:154, the boundary check evaluates the previous frame value (zoomScale.get()) rather than evaluating and clamping the incoming newZoomScale:
When pinching in rapidly, a scale update pushes zoomScale past zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max (e.g. past 30). On all subsequent onChange events, zoomScale.get() <= maxBounce evaluates to false. As a result, when the user reverses the pinch direction to zoom out (evt.scale < 1), the block is skipped and zoomScale remains completely frozen until both fingers are lifted.
Pinch gesture unresponsiveness after double-tap reset (Android):
In usePinchGesture.ts:147-150, when evt.numberOfPointers !== 2, it toggles .enabled(false) via scheduleOnRN(setPinchEnabled, false). With the upgrade to react-native-gesture-handler 3.3.0, disabling builder-API gesture handlers cancels the scale detector on Android. When double-tap resets scale to 1, pager navigation takes precedence and the pinch detector fails to re-arm.
What changes do you think we should make in order to solve the problem?
Clamp newZoomScale in usePinchGesture.ts:
In onChange, calculate newZoomScale and clamp it using the existing clamp utility between zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min and zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max.
Set zoomScale to this clamped value and derive currentPinchScale from the clamped scale relative to pinchScale.get(). This ensures that even when the pinch exceeds maximum bounce threshold, reversing the pinch to zoom out immediately registers and updates the scale without freezing.
Prevent gesture de-registration on pointer change:
In onChange, when evt.numberOfPointers !== 2, simply return early instead of toggling scheduleOnRN(setPinchEnabled, false). This keeps the gesture recognizer active and avoids breaking the scale detector on Android.
Ensure simultaneous gesture handling with Pager:
In src/components/MultiGestureCanvas/index.tsx, ensure the pinch gesture is allowed to run simultaneously with external pager gestures (similar to how pan gesture is handled), preventing the carousel pager from cancelling pinch activation when zoomed out to scale 1.
What alternative solutions did you explore? (Optional)
Downgrading react-native-gesture-handler to 2.32.0. This is not recommended as it doesn't solve the scale clamping freeze and conflicts with future gesture-handler migrations.
Only allowing zoom-out via double-tap. This degrades user experience since continuous pinch-in to pinch-out is expected standard mobile behavior.
📣 @janiya2! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:
Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
Format:
Contributor details
Your Expensify account email: <REPLACE EMAIL HERE>
Upwork Profile Link: <REPLACE LINK HERE>
Please re-state the problem that we are trying to solve in this issue.
In the image attachment viewer, pinching to max zoom then pinching out does not zoom back out; after a double-tap zoom-out, the pinch gesture becomes completely unresponsive.
What is the root cause of that problem?
The zoom gesture state in the attachment viewer does not reset correctly when the scale hits the maximum clamp: the gesture stays consumed at the ceiling so subsequent pinch-out deltas are ignored, and the double-tap zoom-out desyncs the gesture handler's internal scale state so further pinches register no movement.
What changes do you think we should make in order to solve the problem?
In the image viewer/zoom component, ensure the pinch gesture state (scale shared value and gesture active flag) resets on gesture end and after double-tap, so the responder re-arms at the current scale instead of staying stuck at the max-zoom state. I will verify the repro on Android: pinch to max, pinch out, then double-tap out and pinch again - each step should zoom smoothly, and normal pan/swipe of the viewer should be unaffected.
Please re-state the problem that the issue is addressing
Image attachment - Pinch-to-zoom stops working after reaching maximum zoom and pinch gesture becomes unresponsive after double-tap zoom out on Android.
What is the root cause of that problem?
There are two distinct root causes:
Pinch scale check evaluates previous scale instead of clamping incoming scale:
In \src/components/MultiGestureCanvas/usePinchGesture.ts:154, the boundary check evaluates \zoomScale.get():
\\ ypescript
if (zoomScale.get() >= zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min && zoomScale.get() <= zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max)
\
When pinching in rapidly, a scale update pushes \zoomScale\ slightly past the maximum bounce threshold (e.g. > 30). On subsequent frames, \zoomScale.get() <= maxBounce\ evaluates to \alse. When the user reverses the pinch gesture to zoom out (\evt.scale < 1), the condition is skipped, leaving \zoomScale\ completely frozen until both fingers are released.
Gesture handler destruction and pager cancellation on Android:
In \usePinchGesture.ts:148, when one finger is released (\evt.numberOfPointers !== 2), \scheduleOnRN(setPinchEnabled, false)\ toggles .enabled(false). In
eact-native-gesture-handler\ 3.3.0 on Android, toggling .enabled(false)\ destroys the native scale detector and desyncs pointer tracking.
In \src/components/MultiGestureCanvas/index.tsx:255, \pinchGesture\ is only marked simultaneous with \panGesture, singleTapGesture, doubleTapGesture, omitting \pagerRef. When zoomed out to scale 1 (e.g. after double-tap), horizontal pointer movement causes the carousel pager to claim touches and cancel the pinch gesture.
What changes do you think we should make in order to solve the problem?
In \src/components/MultiGestureCanvas/usePinchGesture.ts:
Clamp
ewZoomScale\ directly between the min and max bounce bounds using the existing \clamp\ helper from ./utils:
\\ ypescript
const minBounce = zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min;
const maxBounce = zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max;
const clampedZoomScale = clamp(newZoomScale, minBounce, maxBounce);
zoomScale.set(clampedZoomScale);
currentPinchScale.set(clampedZoomScale / pinchScale.get());
triggerScaleChangedEvent();
\\
When \evt.numberOfPointers !== 2, simply return early on the UI thread without destroying the native handler via \setPinchEnabled(false).
Remove the unnecessary \pinchEnabled\ state and effect.
In \src/components/MultiGestureCanvas/index.tsx:
Include \simultaneousGestureList\ (which holds \pagerRef) in \pinchGesture.simultaneousWithExternalGesture(panGesture, ...simultaneousGestureList), preventing external carousel pager from cancelling pinch activation when zoomed out to scale 1.
Please re-state the problem that we are trying to solve in this issue.
In the image attachment viewer on Android, pinching in to zoom past or up to the maximum zoom ceiling prevents pinching back out to zoom out. After resetting the scale back to 1 via double-tap, the pinch-to-zoom gesture becomes completely unresponsive.
What is the root cause of that problem?
There are two distinct root causes:
Scale boundary check evaluates prior zoom scale instead of clamping target scale:
In src/components/MultiGestureCanvas/usePinchGesture.ts:154:
When a user pinches in rapidly, a scale delta drives zoomScale slightly past the maximum bounce threshold (e.g. > 30). On subsequent onChange events, zoomScale.get() <= maxBounce evaluates to false. When the user reverses gesture direction to pinch out (evt.scale < 1), this conditional guard is skipped, leaving zoomScale completely frozen until both fingers are lifted.
Native scale detector destruction and carousel pager preemption on Android:
In usePinchGesture.ts:147-150, whenever one pointer is lifted (evt.numberOfPointers !== 2), scheduleOnRN(setPinchEnabled, false) toggles .enabled(false). Under react-native-gesture-handler on Android, toggling .enabled(false) during an active gesture dismantles the native scale gesture detector and drops pointer state tracking.
In src/components/MultiGestureCanvas/index.tsx:255, pinchGesture is configured via .simultaneousWithExternalGesture(panGesture, singleTapGesture, doubleTapGesture), omitting pagerRef. When zoomed out to scale 1 (via double-tap), horizontal movement causes the external carousel pager to claim touches and preempt the pinch gesture detector.
What changes do you think we should make in order to solve the problem?
In src/components/MultiGestureCanvas/usePinchGesture.ts:
Calculate newZoomScale and clamp it between the minimum and maximum bounce bounds using the existing clamp helper from ./utils:
When evt.numberOfPointers !== 2, simply return early on the UI thread without disabling the native gesture handler via setPinchEnabled(false).
Remove the unnecessary pinchEnabled state, effect, and unused imports.
In src/components/MultiGestureCanvas/index.tsx:
Pass ...panGestureSimultaneousList (which includes pagerRef) to pinchGesture.simultaneousWithExternalGesture(panGesture, ...panGestureSimultaneousList). This prevents the external carousel pager from preempting pinch gesture activation when the image is at base scale 1.
What alternative solutions did you explore? (Optional)
Only clamping newZoomScale without updating currentPinchScale: this would cause focal translation drift during bounce clamping. Deriving currentPinchScale = newZoomScale / pinchScale.get() keeps focal translations perfectly synchronized with the clamped visual scale.
Please re-state the problem that we are trying to solve in this issue.
In the image attachment viewer, zooming in past the maximum zoom boundary causes the canvas to freeze at the maximum ceiling, ignoring any pinch gestures to zoom back out. In addition, after resetting the zoom level back to 1 via double-tap on Android, pinch-to-zoom stops working completely.
What is the root cause of that problem?
There are two main issues causing this behavior, plus a simultaneous gesture conflict with the carousel pager:
The scale boundary check looks at the previous frame scale instead of clamping the target scale:
In src/components/MultiGestureCanvas/usePinchGesture.ts (lines 152-160):
constnewZoomScale=pinchScale.get()*evt.scale;// Limit the zoom scale to zoom range including bounce rangeif(zoomScale.get()>=zoomRange.min*ZOOM_RANGE_BOUNCE_FACTORS.min&&zoomScale.get()<=zoomRange.max*ZOOM_RANGE_BOUNCE_FACTORS.max){zoomScale.set(newZoomScale);currentPinchScale.set(evt.scale);triggerScaleChangedEvent();}
The if condition checks zoomScale.get(), which is the scale value from the previous frame, rather than checking or clamping newZoomScale.
With DEFAULT_ZOOM_RANGE of [1, 20] and ZOOM_RANGE_BOUNCE_FACTORS of [0.7, 1.5], the upper bounce limit is 30 (20 * 1.5).
During a fast zoom, a single gesture update can push zoomScale slightly past 30 (for example, 30.15). Once that happens, zoomScale.get() <= 30 evaluates to false on every subsequent onChange call.
When the user reverses the gesture to zoom back out (evt.scale < 1), zoomScale.get() is still stuck at 30.15. Because the condition keeps checking the stale out-of-bounds value, the entire update block is skipped. Neither zoomScale nor currentPinchScale updates, leaving the zoom completely frozen until the user lifts both fingers.
Toggling setPinchEnabled(false) via React state destroys Android's native gesture detector:
In src/components/MultiGestureCanvas/usePinchGesture.ts (lines 108-114 and 147-150):
When one finger is lifted (which almost always happens when ending a pinch gesture), scheduleOnRN(setPinchEnabled, false) triggers a React state update on the JS thread to re-render with .enabled(false).
In react-native-gesture-handler on Android, disabling an active gesture handler tears down the underlying native ScaleGestureDetector. By the time useEffect re-enables it, the native detector has lost the ongoing touch stream and will not recognize subsequent pinch gestures until the touch sequence completely resets.
We do not need React state or component re-renders here at all. Returning early on the UI thread when evt.numberOfPointers !== 2 prevents content jumping while keeping the native detector intact.
pinchGesture is missing pagerRef in its simultaneous gesture configuration:
In src/components/MultiGestureCanvas/index.tsx (lines 219-222, 239, and 255): panGesture is configured with .simultaneousWithExternalGesture(...panGestureSimultaneousList), which includes pagerRef.
However, pinchGesture only includes: .simultaneousWithExternalGesture(panGesture, singleTapGesture, doubleTapGesture);
omitting pagerRef.
When an image is reset to scale 1 (like after a double-tap zoom out), the carousel pager re-enables horizontal page swiping. Because pinchGesture is not marked simultaneous with pagerRef, any horizontal finger movement during a new pinch causes the carousel pager to claim the touch stream, canceling the pinch gesture on Android.
What changes do you think we should make in order to solve the problem?
In src/components/MultiGestureCanvas/usePinchGesture.ts:
Import clamp from ./utils.
Remove the pinchEnabled state, its useEffect hook, and .enabled(pinchEnabled) from Gesture.Pinch(). Remove the unused useState and useEffect imports.
In onStart, cancel any running animations on zoomScale and pinchBounceTranslateX/Y, sync pinchScale to zoomScale.get(), and reset bounce translations. This ensures that starting a pinch while a bounce spring animation is settling catches the content seamlessly at its current scale:
Note on currentPinchScale: Setting currentPinchScale to clampedZoomScale / pinchScale.get() instead of raw evt.scale is essential. If left as evt.scale, the focal translation offset continues shifting while the visual scale is clamped at the ceiling. That causes the focal point to drift across the canvas during the pinch and visibly snap back upon release.
In the translation update block, when the scale is within normal bounds (zoomScale.get() >= zoomRange.min && zoomScale.get() <= zoomRange.max), explicitly reset pinchBounceTranslateX.set(0) and pinchBounceTranslateY.set(0). This handles the case where a user pinches into the over-zoom bounce zone and then pinches back into normal range within the same gesture, ensuring no leftover bounce offset triggers an unwanted release spring animation.
In src/components/MultiGestureCanvas/index.tsx:
Pass ...panGestureSimultaneousList to pinchGesture.simultaneousWithExternalGesture(panGesture, ...panGestureSimultaneousList) so that pinchGesture operates simultaneously with pagerRef alongside panGesture, singleTapGesture, and doubleTapGesture.
What alternative solutions did you explore? (Optional)
Clamping newZoomScale but keeping currentPinchScale.set(evt.scale):
While this prevents the scale freeze, newPinchTranslateX/Y relies on currentPinchScale to position content under the user fingers. If currentPinchScale keeps increasing while zoomScale is clamped, the focal point drifts across the canvas during the pinch and visibly snaps back upon release. Scaling currentPinchScale proportionally to the clamped scale keeps the touch focal point and visual content locked together.
Re-enabling pinchEnabled via requestAnimationFrame or setTimeout:
This still crosses the JS bridge and triggers component re-renders, causing the same native gesture detector recreation on Android. Handling the pointer count check directly on the UI thread with an early return is simpler, faster, and avoids re-renders altogether.
Test Plan
Test Case 1: Pinch past maximum zoom and pinch back out
Open any chat report and tap an image attachment to open the lightbox viewer.
Spread two fingers outward past the maximum zoom boundary (scale 20) into the bounce zone.
Without lifting fingers, pinch fingers back together to zoom out.
Verify: The image zooms back out smoothly without freezing or locking at the maximum ceiling.
Test Case 2: Double-tap zoom out followed by pinch-to-zoom (Android)
In the lightbox viewer, pinch to zoom into the image.
Release both fingers.
Double-tap the image to reset zoom back to base scale (1.0).
Pinch to zoom into the image again.
Verify: The pinch gesture activates immediately on Android and zooms smoothly without becoming unresponsive.
Test Case 3: Reversal from over-zoom bounce back into normal scale
Spread two fingers outward past maximum zoom into the bounce zone.
Without lifting fingers, pinch back inward into the normal range (e.g. around 15x) and release.
Verify: The image settles at 15x immediately without an unwanted spring bounce animation.
Test Case 4: Carousel pager navigation at base scale (1.0)
In a chat report with multiple image attachments, tap an image to open the attachment carousel.
At scale 1.0, swipe left and right between attachments.
Verify: The carousel navigates between attachments smoothly.
On any attachment at scale 1.0, pinch to zoom in.
Verify: The pinch gesture activates cleanly; the carousel pager does not cancel or preempt the pinch.
Test Case 5: Single and double-tap interactions
Tap once on the canvas.
Verify: Header and footer overlays toggle visibility cleanly.
Double-tap to zoom in, then double-tap to zoom out.
Verify: Double-tap zoom transitions operate smoothly across all platforms.
Please re-state the problem that we are trying to solve in this issue.
In the image attachment preview on mobile and web, when zooming into an image using a pinch gesture until reaching maximum zoom, the pinch-to-zoom gesture completely freezes. Reversing the gesture to zoom back out does not respond, leaving the image stuck at maximum zoom until the user lifts both fingers from the screen.
What is the root cause of that problem?
In src/components/MultiGestureCanvas/usePinchGesture.ts:152-160:
Stale Zoom Check vs. Clamped New Scale: The boundary guard evaluates the previous frame's scale value (zoomScale.get()) against the bounce factor boundaries (e.g. 20 * 1.5 = 30) instead of computing and clamping the incoming newZoomScale.
When the user pinches outward quickly, a frame update sets zoomScale past 30. On subsequent onChange events, zoomScale.get() <= maxBounce evaluates to false.
Consequently, when the user reverses finger direction to pinch inward (evt.scale < 1), the conditional block is completely skipped because zoomScale.get() is already above 30. The scale freezes and cannot update until onEnd runs when both fingers are lifted.
Additionally, toggling scheduleOnRN(setPinchEnabled, false) when numberOfPointers !== 2 de-registers the builder API scale detector in react-native-gesture-handler 3.3.0 on Android, causing intermittent gesture dropped states.
What changes do you think we should make in order to solve the problem?
In src/components/MultiGestureCanvas/usePinchGesture.ts:
Compute newZoomScale and clamp it within the allowed bounce factor range using Expensify's existing clamp utility:
This guarantees that even if a pinch reaches the maximum threshold, reversing the gesture immediately produces a scale smaller than the upper clamp, allowing continuous smooth zooming without freezing.
In pointer count handling:
When evt.numberOfPointers !== 2, return early instead of toggling scheduleOnRN(setPinchEnabled, false). This avoids breaking RNGH 3.3's native Android scale detector lifecycle.
In MultiGestureCanvas/index.tsx:
Ensure the pinch gesture runs simultaneously with the carousel pager's external gesture, preventing native pager touch interception from canceling in-flight pinches.
What alternative solutions did you explore? (Optional)
Downgrading react-native-gesture-handler to 2.32.0. This was dismissed because it does not fix the mathematical scale boundary freeze in usePinchGesture.ts and blocks the upcoming migration to RNGH 3.x hooks.
Activity
applause-bot commentedon Oct 9, 2026
While Applause doesn't embed attachments for this bug, here are the links for quick view:
https://platform.applause.com/services/links/v1/external/663223e627b1272172512388985b1ecaccc13f53f2af5df010a8e278a48819bc
MelvinBot commentedon Oct 9, 2026
Proposal
What is the root cause of that problem?
There are two causes.
usePinchGesture.ts:152-159,onChangechecks the oldzoomScaleagainst the bounce range (0.7–30), not the new one. The first event that goes past 30 still gets written. After that, every later event fails the check, sozoomScalefreezes until you lift both fingers. Reversing the pinch mid-gesture is ignored.ACTION_DOWN. Our code cancels the handler by toggling.enabled(false)whenever the pointer count isn't 2 (usePinchGesture.ts:145-150). That can leave it with no detector, so it never activates.What changes do you think we should make in order to solve the problem?
usePinchGestureonChange, compute the new scale and clamp it to the bounce range with the existingclamputil. Then always write the clamped value. DerivecurrentPinchScalefrom the clamped value so translation stays in sync.numberOfPointers !== 2, return early instead of callingsetPinchEnabled(false). Remove thepinchEnabledstate and the effect that re-enables it.MultiGestureCanvas/index.tsx. This stops the pager from cancelling an active pinch.Test steps 2 and 3 on an Android device. To confirm that the RNGH upgrade caused part 2, run the repro on 9.5.1-9, which still uses RNGH 2.32.0.
What alternative solutions did you explore? (Optional)
Roll RNGH back to 2.32.0. This would undo the Android regression but would not fix the clamp bug. It would also block Migrate react-native-gesture-handler builder API to the 3.x hook API, which may fix part 2 if it lands first.
Investigation details
DEFAULT_ZOOM_RANGEis 1–20 and the bounce factors are 0.7/1.5 (constants.ts:17-27). This gives the 0.7–30 window. The same bug blocks pinch-in below 0.7.onEndsprings the scale back to 20 (usePinchGesture.ts:197-201), so lifting fingers restores the gesture. This matches the "stuck until release" behavior.usePinchGesturelogic is unchanged since 9.5.0-5. RNGH changed from 2.32.0 in 9.5.1-9 to 3.3.0 in 9.5.2-4 and later, including the reported 9.5.4-3.PinchGestureHandler.kt(detector set up only onACTION_DOWNinUNDETERMINED) andRNGestureHandlerRootHelper.requestDisallowInterceptTouchEvent→cancelAllLegacyHandlers().onBegin/onStart/onFinalizeto tell whether the pinch never activates or is cancelled.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/103648)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
view run
melvin-bot commentedon Oct 9, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @situchan (
External)[-]Image attachment - Pinch-to-zoom stops working after reaching maximum zoom[/-][+][$175] Image attachment - Pinch-to-zoom stops working after reaching maximum zoom[/+]melvin-bot commentedon Oct 9, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022108634660545756272
janiya2 commentedon Oct 9, 2026
Proposal
What is the root cause of that problem?
There are two primary causes for this issue:
Pinch-out freezes after reaching max zoom:
In
src/components/MultiGestureCanvas/usePinchGesture.ts:154, the boundary check evaluates the previous frame value (zoomScale.get()) rather than evaluating and clamping the incomingnewZoomScale:When pinching in rapidly, a scale update pushes
zoomScalepastzoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max(e.g. past30). On all subsequentonChangeevents,zoomScale.get() <= maxBounceevaluates tofalse. As a result, when the user reverses the pinch direction to zoom out (evt.scale < 1), the block is skipped andzoomScaleremains completely frozen until both fingers are lifted.Pinch gesture unresponsiveness after double-tap reset (Android):
In
usePinchGesture.ts:147-150, whenevt.numberOfPointers !== 2, it toggles.enabled(false)viascheduleOnRN(setPinchEnabled, false). With the upgrade toreact-native-gesture-handler3.3.0, disabling builder-API gesture handlers cancels the scale detector on Android. When double-tap resets scale to1, pager navigation takes precedence and the pinch detector fails to re-arm.What changes do you think we should make in order to solve the problem?
Clamp
newZoomScaleinusePinchGesture.ts:In
onChange, calculatenewZoomScaleand clamp it using the existingclamputility betweenzoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.minandzoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max.Set
zoomScaleto this clamped value and derivecurrentPinchScalefrom the clamped scale relative topinchScale.get(). This ensures that even when the pinch exceeds maximum bounce threshold, reversing the pinch to zoom out immediately registers and updates the scale without freezing.Prevent gesture de-registration on pointer change:
In
onChange, whenevt.numberOfPointers !== 2, simply return early instead of togglingscheduleOnRN(setPinchEnabled, false). This keeps the gesture recognizer active and avoids breaking the scale detector on Android.Ensure simultaneous gesture handling with Pager:
In
src/components/MultiGestureCanvas/index.tsx, ensure the pinch gesture is allowed to run simultaneously with external pager gestures (similar to how pan gesture is handled), preventing the carousel pager from cancelling pinch activation when zoomed out to scale1.What alternative solutions did you explore? (Optional)
react-native-gesture-handlerto2.32.0. This is not recommended as it doesn't solve the scale clamping freeze and conflicts with future gesture-handler migrations.melvin-bot commentedon Oct 9, 2026
📣 @janiya2! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:
Format:
janiya2 commentedon Oct 9, 2026
Contributor details
Your Expensify account email: janithad2@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/janithad
melvin-bot commentedon Oct 9, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
khizzcr7 commentedon Oct 10, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
In the image attachment viewer, pinching to max zoom then pinching out does not zoom back out; after a double-tap zoom-out, the pinch gesture becomes completely unresponsive.
What is the root cause of that problem?
The zoom gesture state in the attachment viewer does not reset correctly when the scale hits the maximum clamp: the gesture stays consumed at the ceiling so subsequent pinch-out deltas are ignored, and the double-tap zoom-out desyncs the gesture handler's internal scale state so further pinches register no movement.
What changes do you think we should make in order to solve the problem?
In the image viewer/zoom component, ensure the pinch gesture state (scale shared value and gesture active flag) resets on gesture end and after double-tap, so the responder re-arms at the current scale instead of staying stuck at the max-zoom state. I will verify the repro on Android: pinch to max, pinch out, then double-tap out and pinch again - each step should zoom smoothly, and normal pan/swipe of the viewer should be unaffected.
jloa-dev commentedon Oct 10, 2026
Proposal
Please re-state the problem that the issue is addressing
Image attachment - Pinch-to-zoom stops working after reaching maximum zoom and pinch gesture becomes unresponsive after double-tap zoom out on Android.
What is the root cause of that problem?
There are two distinct root causes:
In \src/components/MultiGestureCanvas/usePinchGesture.ts:154, the boundary check evaluates \zoomScale.get():
\\ ypescript
if (zoomScale.get() >= zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min && zoomScale.get() <= zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max)
\
When pinching in rapidly, a scale update pushes \zoomScale\ slightly past the maximum bounce threshold (e.g. > 30). On subsequent frames, \zoomScale.get() <= maxBounce\ evaluates to \alse. When the user reverses the pinch gesture to zoom out (\evt.scale < 1), the condition is skipped, leaving \zoomScale\ completely frozen until both fingers are released.
eact-native-gesture-handler\ 3.3.0 on Android, toggling .enabled(false)\ destroys the native scale detector and desyncs pointer tracking.
What changes do you think we should make in order to solve the problem?
ewZoomScale\ directly between the min and max bounce bounds using the existing \clamp\ helper from ./utils:
\\ ypescript
const minBounce = zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min;
const maxBounce = zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max;
const clampedZoomScale = clamp(newZoomScale, minBounce, maxBounce);
zoomScale.set(clampedZoomScale);
currentPinchScale.set(clampedZoomScale / pinchScale.get());
triggerScaleChangedEvent();
\\
Tested branch pre-coded on:
https://github.com/jloa-dev/App/tree/fix/103648-pinch-zoom-max-clamp
RajGahoi commentedon Oct 10, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
In the image attachment viewer on Android, pinching in to zoom past or up to the maximum zoom ceiling prevents pinching back out to zoom out. After resetting the scale back to 1 via double-tap, the pinch-to-zoom gesture becomes completely unresponsive.
What is the root cause of that problem?
There are two distinct root causes:
Scale boundary check evaluates prior zoom scale instead of clamping target scale:
In
src/components/MultiGestureCanvas/usePinchGesture.ts:154:When a user pinches in rapidly, a scale delta drives
zoomScaleslightly past the maximum bounce threshold (e.g.> 30). On subsequentonChangeevents,zoomScale.get() <= maxBounceevaluates tofalse. When the user reverses gesture direction to pinch out (evt.scale < 1), this conditional guard is skipped, leavingzoomScalecompletely frozen until both fingers are lifted.Native scale detector destruction and carousel pager preemption on Android:
usePinchGesture.ts:147-150, whenever one pointer is lifted (evt.numberOfPointers !== 2),scheduleOnRN(setPinchEnabled, false)toggles.enabled(false). Underreact-native-gesture-handleron Android, toggling.enabled(false)during an active gesture dismantles the native scale gesture detector and drops pointer state tracking.src/components/MultiGestureCanvas/index.tsx:255,pinchGestureis configured via.simultaneousWithExternalGesture(panGesture, singleTapGesture, doubleTapGesture), omittingpagerRef. When zoomed out to scale1(via double-tap), horizontal movement causes the external carousel pager to claim touches and preempt the pinch gesture detector.What changes do you think we should make in order to solve the problem?
In
src/components/MultiGestureCanvas/usePinchGesture.ts:newZoomScaleand clamp it between the minimum and maximum bounce bounds using the existingclamphelper from./utils:evt.numberOfPointers !== 2, simply return early on the UI thread without disabling the native gesture handler viasetPinchEnabled(false).pinchEnabledstate, effect, and unused imports.In
src/components/MultiGestureCanvas/index.tsx:...panGestureSimultaneousList(which includespagerRef) topinchGesture.simultaneousWithExternalGesture(panGesture, ...panGestureSimultaneousList). This prevents the external carousel pager from preempting pinch gesture activation when the image is at base scale1.Tested and pre-coded on branch:
https://github.com/RajGahoi/App/tree/feature/103648-pinch-zoom-max-clamp
What alternative solutions did you explore? (Optional)
newZoomScalewithout updatingcurrentPinchScale: this would cause focal translation drift during bounce clamping. DerivingcurrentPinchScale = newZoomScale / pinchScale.get()keeps focal translations perfectly synchronized with the clamped visual scale.Contributor details
Your Expensify account email: gahoiraj01@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~01b77415f16d156068
melvin-bot commentedon Oct 10, 2026
Details
taieddie commentedon Oct 10, 2026
Contributor details
Your Expensify account email: taieddie8@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~01af851c9a7f771775
Proposal
Please re-state the problem that we are trying to solve in this issue.
In the image attachment viewer, zooming in past the maximum zoom boundary causes the canvas to freeze at the maximum ceiling, ignoring any pinch gestures to zoom back out. In addition, after resetting the zoom level back to 1 via double-tap on Android, pinch-to-zoom stops working completely.
What is the root cause of that problem?
There are two main issues causing this behavior, plus a simultaneous gesture conflict with the carousel pager:
The scale boundary check looks at the previous frame scale instead of clamping the target scale:
In
src/components/MultiGestureCanvas/usePinchGesture.ts(lines 152-160):The
ifcondition checkszoomScale.get(), which is the scale value from the previous frame, rather than checking or clampingnewZoomScale.With
DEFAULT_ZOOM_RANGEof[1, 20]andZOOM_RANGE_BOUNCE_FACTORSof[0.7, 1.5], the upper bounce limit is 30 (20 * 1.5).During a fast zoom, a single gesture update can push
zoomScaleslightly past 30 (for example, 30.15). Once that happens,zoomScale.get() <= 30evaluates tofalseon every subsequentonChangecall.When the user reverses the gesture to zoom back out (
evt.scale < 1),zoomScale.get()is still stuck at 30.15. Because the condition keeps checking the stale out-of-bounds value, the entire update block is skipped. NeitherzoomScalenorcurrentPinchScaleupdates, leaving the zoom completely frozen until the user lifts both fingers.Toggling
setPinchEnabled(false)via React state destroys Android's native gesture detector:In
src/components/MultiGestureCanvas/usePinchGesture.ts(lines 108-114 and 147-150):When one finger is lifted (which almost always happens when ending a pinch gesture),
scheduleOnRN(setPinchEnabled, false)triggers a React state update on the JS thread to re-render with.enabled(false).In
react-native-gesture-handleron Android, disabling an active gesture handler tears down the underlying nativeScaleGestureDetector. By the timeuseEffectre-enables it, the native detector has lost the ongoing touch stream and will not recognize subsequent pinch gestures until the touch sequence completely resets.We do not need React state or component re-renders here at all. Returning early on the UI thread when
evt.numberOfPointers !== 2prevents content jumping while keeping the native detector intact.pinchGestureis missingpagerRefin its simultaneous gesture configuration:In
src/components/MultiGestureCanvas/index.tsx(lines 219-222, 239, and 255):panGestureis configured with.simultaneousWithExternalGesture(...panGestureSimultaneousList), which includespagerRef.However,
pinchGestureonly includes:.simultaneousWithExternalGesture(panGesture, singleTapGesture, doubleTapGesture);omitting
pagerRef.When an image is reset to scale 1 (like after a double-tap zoom out), the carousel pager re-enables horizontal page swiping. Because
pinchGestureis not marked simultaneous withpagerRef, any horizontal finger movement during a new pinch causes the carousel pager to claim the touch stream, canceling the pinch gesture on Android.What changes do you think we should make in order to solve the problem?
In
src/components/MultiGestureCanvas/usePinchGesture.ts:clampfrom./utils.pinchEnabledstate, itsuseEffecthook, and.enabled(pinchEnabled)fromGesture.Pinch(). Remove the unuseduseStateanduseEffectimports.onStart, cancel any running animations onzoomScaleandpinchBounceTranslateX/Y, syncpinchScaletozoomScale.get(), and reset bounce translations. This ensures that starting a pinch while a bounce spring animation is settling catches the content seamlessly at its current scale:onChange, return early on the UI thread whenevt.numberOfPointers !== 2:newZoomScalebetween the minimum and maximum bounce bounds usingclamp:currentPinchScale: SettingcurrentPinchScaletoclampedZoomScale / pinchScale.get()instead of rawevt.scaleis essential. If left asevt.scale, the focal translation offset continues shifting while the visual scale is clamped at the ceiling. That causes the focal point to drift across the canvas during the pinch and visibly snap back upon release.zoomScale.get() >= zoomRange.min && zoomScale.get() <= zoomRange.max), explicitly resetpinchBounceTranslateX.set(0)andpinchBounceTranslateY.set(0). This handles the case where a user pinches into the over-zoom bounce zone and then pinches back into normal range within the same gesture, ensuring no leftover bounce offset triggers an unwanted release spring animation.In
src/components/MultiGestureCanvas/index.tsx:...panGestureSimultaneousListtopinchGesture.simultaneousWithExternalGesture(panGesture, ...panGestureSimultaneousList)so thatpinchGestureoperates simultaneously withpagerRefalongsidepanGesture,singleTapGesture, anddoubleTapGesture.What alternative solutions did you explore? (Optional)
Clamping
newZoomScalebut keepingcurrentPinchScale.set(evt.scale):While this prevents the scale freeze,
newPinchTranslateX/Yrelies oncurrentPinchScaleto position content under the user fingers. IfcurrentPinchScalekeeps increasing whilezoomScaleis clamped, the focal point drifts across the canvas during the pinch and visibly snaps back upon release. ScalingcurrentPinchScaleproportionally to the clamped scale keeps the touch focal point and visual content locked together.Re-enabling
pinchEnabledviarequestAnimationFrameorsetTimeout:This still crosses the JS bridge and triggers component re-renders, causing the same native gesture detector recreation on Android. Handling the pointer count check directly on the UI thread with an early return is simpler, faster, and avoids re-renders altogether.
Test Plan
Test Case 1: Pinch past maximum zoom and pinch back out
Test Case 2: Double-tap zoom out followed by pinch-to-zoom (Android)
Test Case 3: Reversal from over-zoom bounce back into normal scale
Test Case 4: Carousel pager navigation at base scale (1.0)
Test Case 5: Single and double-tap interactions
melvin-bot commentedon Oct 10, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
melvin-bot commentedon Oct 12, 2026
@situchan Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!
camesenin commentedon Oct 13, 2026
Contributor details
Your Expensify account email: camesenin@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~0170d6b02e9440e604
Proposal
Please re-state the problem that we are trying to solve in this issue.
In the image attachment preview on mobile and web, when zooming into an image using a pinch gesture until reaching maximum zoom, the pinch-to-zoom gesture completely freezes. Reversing the gesture to zoom back out does not respond, leaving the image stuck at maximum zoom until the user lifts both fingers from the screen.
What is the root cause of that problem?
In
src/components/MultiGestureCanvas/usePinchGesture.ts:152-160:zoomScale.get()) against the bounce factor boundaries (e.g.20 * 1.5 = 30) instead of computing and clamping the incomingnewZoomScale.zoomScalepast30. On subsequentonChangeevents,zoomScale.get() <= maxBounceevaluates tofalse.evt.scale < 1), the conditional block is completely skipped becausezoomScale.get()is already above30. The scale freezes and cannot update untilonEndruns when both fingers are lifted.scheduleOnRN(setPinchEnabled, false)whennumberOfPointers !== 2de-registers the builder API scale detector inreact-native-gesture-handler3.3.0 on Android, causing intermittent gesture dropped states.What changes do you think we should make in order to solve the problem?
In
src/components/MultiGestureCanvas/usePinchGesture.ts:newZoomScaleand clamp it within the allowed bounce factor range using Expensify's existingclamputility:zoomScaletoclampedNewZoomScale:In pointer count handling:
evt.numberOfPointers !== 2, return early instead of togglingscheduleOnRN(setPinchEnabled, false). This avoids breaking RNGH 3.3's native Android scale detector lifecycle.In
MultiGestureCanvas/index.tsx:What alternative solutions did you explore? (Optional)
Downgrading
react-native-gesture-handlerto 2.32.0. This was dismissed because it does not fix the mathematical scale boundary freeze inusePinchGesture.tsand blocks the upcoming migration to RNGH 3.x hooks.melvin-bot commentedon Oct 13, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
melvin-bot commentedon Oct 14, 2026
@situchan Eep! 4 days overdue now. Issues have feelings too...