Skip to content

feat(android): implement preventNativeDismiss / onNativeDismissCancelled for formSheet#3873

Open
wildseansy wants to merge 3 commits intosoftware-mansion:mainfrom
wildseansy:feat/android-formsheet-prevent-native-dismiss
Open

feat(android): implement preventNativeDismiss / onNativeDismissCancelled for formSheet#3873
wildseansy wants to merge 3 commits intosoftware-mansion:mainfrom
wildseansy:feat/android-formsheet-prevent-native-dismiss

Conversation

@wildseansy
Copy link
Copy Markdown

@wildseansy wildseansy commented Apr 11, 2026

Problem

preventNativeDismiss and onNativeDismissCancelled were no-ops on Android. Because of this, navigation libraries (e.g. React Navigation) that rely on usePreventRemove + onNativeDismissCancelled to implement "unsaved changes" guards work correctly on iOS but are completely bypassed on Android formSheet — the sheet dismisses natively before the JS layer can intercept it.

Note: gestureEnabled / isDraggable support is tracked separately in #2937. This PR does not touch that prop.

Root cause

iOS exposes UIAdaptivePresentationControllerDelegate.presentationControllerShouldDismiss — a synchronous hook that can return NO before any visual motion occurs. Android's BottomSheetBehavior has no equivalent. By the time any callback fires, the sheet has already moved.

Solution

Implement the closest Android equivalent using BottomSheetBehavior.isHideable:

Step What happens
preventNativeDismiss=true set BottomSheetBehavior.isHideable = false → sheet cannot reach STATE_HIDDEN via gesture
User drags sheet downward onSlide fires; slideOffset < -0.05f flags a dismiss attempt
User releases Behavior snaps sheet back to last stable detent
Stable state fires onNativeDismissCancelled is dispatched to JS
JS receives event App responds — e.g. shows a "Discard changes?" confirmation

Runtime changes to preventNativeDismiss (e.g. a form going from clean → dirty) are reflected immediately via a property setter that updates BottomSheetBehavior.isHideable without requiring a re-mount.

The sheet moves slightly before bouncing back (unlike iOS where the gesture is cancelled before any visual motion), but the JS contract is identical on both platforms: preventNativeDismiss blocks the dismissal and onNativeDismissCancelled fires so the app can respond.

Example usage

The following pattern works on iOS today. After this PR, it also works on Android formSheet with no changes required in application code:

import { usePreventRemove } from '@react-navigation/native';
import { useState } from 'react';
import { Alert } from 'react-native';

function EditProfileScreen() {
  const [isDirty, setIsDirty] = useState(false);

  usePreventRemove(isDirty, ({ data }) => {
    // iOS:     fires because presentationControllerShouldDismiss returned NO
    // Android: fires via onNativeDismissCancelled after the sheet bounces back
    Alert.alert(
      'Discard changes?',
      'You have unsaved changes. Leave anyway?',
      [
        { text: 'Stay', style: 'cancel' },
        {
          text: 'Discard',
          style: 'destructive',
          onPress: () => navigation.dispatch(data.action),
        },
      ],
    );
  });

  return (
    <TextInput onChangeText={() => setIsDirty(true)} />
  );
}

Demo of fix in action

demo.mov

Files changed

File Change
Screen.kt Add isPreventNativeDismiss with a setter that keeps BottomSheetBehavior.isHideable in sync at runtime
ScreenViewManager.kt Wire up setPreventNativeDismiss (was a no-op); register onNativeDismissCancelled event
SheetDelegate.kt Apply isHideable = !screen.isPreventNativeDismiss during setup; SheetStateObserver tracks drag-toward-dismiss and dispatches event on bounce-back
ScreenEventEmitter.kt Add dispatchOnNativeDismissCancelled()
ScreenNativeDismissCancelledEvent.kt New event class (topNativeDismissCancelled)
src/types.tsx Extend @platform annotations for preventNativeDismiss and onNativeDismissCancelled to include Android

Known differences from iOS

  • The sheet moves slightly before bouncing back. iOS cancels the gesture before any visual motion via presentationControllerShouldDismiss. Android has no pre-gesture synchronous interception point.
  • DISMISS_ATTEMPT_SLIDE_THRESHOLD = -0.05f is a heuristic. Open to feedback on whether this value needs tuning.
  • Only formSheet presentation is affected. pageSheet falls back to modal on Android and is already JS-navigation-controlled.

wildseansy and others added 2 commits April 10, 2026 18:15
Android's BottomSheetBehavior has no synchronous 'shouldDismiss' hook
analogous to iOS's presentationControllerShouldDismiss, so the
gestureEnabled and preventNativeDismiss props were previously no-ops on
Android formSheet screens.

This commit wires up preventNativeDismiss for formSheet on Android using
the closest available equivalent:

- When preventNativeDismiss=true, BottomSheetBehavior.isHideable is set
  to false so the sheet cannot reach STATE_HIDDEN via a drag gesture.
  The sheet instead bounces back to its last stable detent.

- SheetStateObserver tracks downward drag attempts via onSlide
  (slideOffset < DISMISS_ATTEMPT_SLIDE_THRESHOLD). When the sheet
  subsequently settles to a non-hidden stable state, the new
  onNativeDismissCancelled event is dispatched to JS — matching the
  existing iOS behaviour.

- isPreventNativeDismiss uses a property setter so runtime changes
  (e.g. a form transitioning from clean → dirty) are reflected
  immediately in BottomSheetBehavior without requiring a re-mount.

- gestureEnabled is also respected during initial behaviour setup
  (isDraggable = screen.isGestureEnabled) so both props work correctly
  together.

Files changed:
- Screen.kt: add isPreventNativeDismiss property with live setter
- ScreenViewManager.kt: connect setPreventNativeDismiss + register event
- SheetDelegate.kt: apply isHideable, track drag attempts, fire event
- ScreenEventEmitter.kt: add dispatchOnNativeDismissCancelled()
- ScreenNativeDismissCancelledEvent.kt: new event class
- src/types.tsx: extend @platform annotations to include android

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
gestureEnabled / isDraggable support is already tracked in
software-mansion#2937.  This PR focuses
solely on preventNativeDismiss / onNativeDismissCancelled to
support usePreventRemove on Android formSheet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@wildseansy wildseansy changed the title feat(android): implement preventNativeDismiss for formSheet feat(android): implement preventNativeDismiss / onNativeDismissCancelled for formSheet Apr 11, 2026
The previous approach set isHideable=false when preventNativeDismiss=true
to block BottomSheetBehavior from reaching STATE_HIDDEN. On a 1-detent
sheet, BottomSheetBehavior responds by settling to STATE_COLLAPSED instead,
which SheetUtils.detentIndexFromSheetState does not handle (only STATE_HIDDEN
and STATE_EXPANDED are valid for detentCount=1), causing an
IllegalArgumentException crash.

The fix keeps isHideable=true so STATE_COLLAPSED is never reached.
SheetStateObserver now intercepts STATE_HIDDEN directly: when
isPreventNativeDismiss is true and the sheet reaches STATE_HIDDEN, we
immediately re-expand to lastStableState and dispatch onNativeDismissCancelled
so the JS layer can react (e.g. show a "discard changes?" dialog).

Also removes the onSlide hasDraggedTowardDismiss tracking and the
DISMISS_ATTEMPT_SLIDE_THRESHOLD constant, which were artefacts of the
old approach.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant