Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/pluggableWidgets/bottom-sheet-native/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Fixed

- Fixed the keyboard covering text inputs on iOS in a modal bottom sheet with custom rendering. The sheet now moves above the keyboard when an input is focused, and back down once it is dismissed.

## [5.3.2] - 2026-8-4

### Fixed

- Fixed bottomsheet issue to close when the trigger attribute changes.

## [5.3.1] - 2026-7-3

### Fixed

- Fixed flickering issue on Android when opening bottom sheet (both basic and custom render types).
- Improved backdrop animation with smooth fade-in/fade-out transitions.
- Fixed bottomsheet issue to close when the trigger attribute changes.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "bottom-sheet-native",
"widgetName": "BottomSheet",
"version": "5.3.2",
"version": "5.3.3",
"license": "Apache-2.0",
"repository": {
"type": "git",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { render } from "@testing-library/react-native";
import { EmitterSubscription, Keyboard, Platform, TextInput } from "react-native";
import { useBottomSheetInternal } from "@gorhom/bottom-sheet";
import { SheetKeyboardTracker } from "../components/SheetKeyboardTracker";

jest.mock("@gorhom/bottom-sheet", () => ({
useBottomSheetInternal: jest.fn()
}));

interface KeyboardState {
target?: number;
height: number;
}

/** Minimal stand-in for the reanimated shared value the sheet keeps its keyboard state in. */
const createKeyboardState = (): { get: () => KeyboardState; set: (updater: any) => void } => {
let state: KeyboardState = { height: 0 };

return {
get: () => state,
set: updater => {
state = typeof updater === "function" ? updater(state) : updater;
}
};
};

describe("SheetKeyboardTracker", () => {
let animatedKeyboardState: ReturnType<typeof createKeyboardState>;
let showKeyboard: () => void;
let removeListener: jest.Mock;
let platformOsDescriptor: PropertyDescriptor | undefined;

const setPlatform = (os: string): void => {
Object.defineProperty(Platform, "OS", { configurable: true, value: os });
};

beforeEach(() => {
platformOsDescriptor = Object.getOwnPropertyDescriptor(Platform, "OS");
animatedKeyboardState = createKeyboardState();
(useBottomSheetInternal as jest.Mock).mockReturnValue({ animatedKeyboardState });

removeListener = jest.fn();
showKeyboard = () => {
throw new Error("keyboardWillShow was never subscribed to");
};
jest.spyOn(Keyboard, "addListener").mockImplementation((eventName, handler) => {
if (eventName === "keyboardWillShow") {
showKeyboard = () => handler({} as any);
}
return { remove: removeListener } as unknown as EmitterSubscription;
});
setPlatform("ios");
});

afterEach(() => {
jest.restoreAllMocks();
if (platformOsDescriptor) {
Object.defineProperty(Platform, "OS", platformOsDescriptor);
}
});

it("reports a target to the sheet when the keyboard opens on iOS", () => {
render(<SheetKeyboardTracker />);

expect(animatedKeyboardState.get().target).toBeUndefined();

showKeyboard();

// Without a target the sheet discards the keyboard event and never moves.
expect(animatedKeyboardState.get().target).toBeTruthy();
});

it("preserves the rest of the keyboard state", () => {
animatedKeyboardState.set({ height: 336 });
render(<SheetKeyboardTracker />);

showKeyboard();

expect(animatedKeyboardState.get().height).toBe(336);
});

it("reports a new target on every open, so a cached event is always replayed", () => {
render(<SheetKeyboardTracker />);

showKeyboard();
const firstTarget = animatedKeyboardState.get().target;
showKeyboard();

expect(animatedKeyboardState.get().target).not.toBe(firstTarget);
});

it("reports a target even before React Native has recorded the focused input", () => {
// On iOS keyboardWillShow is delivered before TextInput's onFocus, which is what
// fills this ref. Gating on it made the very first focus a no-op, so the sheet
// only moved once a later keyboard event -- e.g. after backgrounding the app --
// found the ref populated. Typed as non-nullable by RN, but null at runtime.
jest.spyOn(TextInput.State, "currentlyFocusedInput").mockReturnValue(null as any);
render(<SheetKeyboardTracker />);

showKeyboard();

expect(animatedKeyboardState.get().target).toBeTruthy();
});

it("does not subscribe on Android, where the OS already moves the input into view", () => {
setPlatform("android");

render(<SheetKeyboardTracker />);

expect(Keyboard.addListener).not.toHaveBeenCalled();
});

it("unsubscribes on unmount", () => {
const { unmount } = render(<SheetKeyboardTracker />);

unmount();

expect(removeListener).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { ReactElement, ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { Modal, Pressable, useWindowDimensions } from "react-native";
import { Modal, Platform, Pressable, useWindowDimensions } from "react-native";
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetProps as GorhomBottomSheetProps,
BottomSheetScrollView
} from "@gorhom/bottom-sheet";
import { EditableValue, ValueStatus } from "mendix";
import { BottomSheetStyle } from "../ui/Styles";
import { SheetKeyboardTracker } from "./SheetKeyboardTracker";

/**
* Move the sheet above the keyboard, and back down once it is dismissed. Applied on iOS
* only, because Android already moves the focused input into view through
* windowSoftInputMode. See SheetKeyboardTracker for why the tracker is needed to make
* these take effect at all.
*/
const keyboardProps: Pick<GorhomBottomSheetProps, "keyboardBehavior" | "keyboardBlurBehavior"> =
Platform.OS === "ios" ? { keyboardBehavior: "interactive", keyboardBlurBehavior: "restore" } : {};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does restore fight with our own close()? If the keyboard hides mid-close, getEvaluatedPosition returns detents[currentIndex] and currentIndex is still 0, so it re-opens. And the index never changes, so no onChange/onClose, nothing retries. Worth trying: dismiss keyboard, tap backdrop right after.


interface CustomModalSheetProps {
triggerAttribute?: EditableValue<boolean>;
Expand Down Expand Up @@ -95,7 +106,9 @@ export const CustomModalSheet = (props: CustomModalSheetProps): ReactElement =>
backgroundStyle={props.styles.container}
handleComponent={null}
handleStyle={{ display: "none" }}
{...keyboardProps}

@YogendraShelke YogendraShelke Sep 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keyboardBehavior: "interactive" clamps the shift at 0, so once the sheet is near the 90% maxDynamicContentSize cap it can't move up enough. And we don't scroll the input into view. Long form with a field at the bottom is probably still broken.

>
<SheetKeyboardTracker />
<BottomSheetScrollView style={[{ flex: 1 }]} contentContainerStyle={{ paddingBottom: 16 }}>
{props.content}
</BottomSheetScrollView>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useEffect, useRef } from "react";
import { Keyboard, Platform } from "react-native";
import { useBottomSheetInternal } from "@gorhom/bottom-sheet";

/**
* Teaches the sheet that a plain React Native TextInput is focused.
*
* @gorhom/bottom-sheet only avoids the keyboard while one of its own
* BottomSheetTextInput components is focused: useAnimatedKeyboard discards every
* "keyboard shown" event while `target` is unset, and BottomSheetTextInput is the only
* component that ever sets it. Mendix Text Box renders a plain TextInput, so the sheet
* never learns an input is focused and keyboardBehavior has nothing to act on.
*
* Reporting a target here closes that gap. The sheet caches the swallowed event and
* replays it as soon as `target` is set, so this works no matter whether our listener
* runs before or after the library's own.
*
* Renders nothing and must be placed inside a BottomSheet, as it reads the sheet's
* internal context.
*
* iOS only: on Android the OS already moves the focused input into view via
* windowSoftInputMode, so shifting the sheet from JS as well would offset it twice.
*/
export const SheetKeyboardTracker = (): null => {
const { animatedKeyboardState } = useBottomSheetInternal();
const targetRef = useRef(0);

useEffect(() => {
if (Platform.OS !== "ios") {
return;
}

const subscription = Keyboard.addListener("keyboardWillShow", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the keyboard is already open when the sheet mounts? No keyboardWillShow, so no target, so we're back to the original bug. Happens with on-change flow that set the trigger while a field is focused. Keyboard.isVisible() on mount?

// Deliberately unconditional: iOS only raises the keyboard for a first
// responder, and the sheet fills a modal, so the focused input is ours.
//
// In particular we cannot consult TextInput.State.currentlyFocusedInput()
// here. React Native fills that ref in TextInput's onFocus handler, which on
// iOS is delivered *after* keyboardWillShow -- the very race the sheet caches
// events for. Gating on it made the first focus a no-op, so the sheet only
// started moving once a later keyboard event found the ref populated.
//
// The sheet treats `target` as an opaque marker: it only checks that one is
// set, and replays a swallowed event whenever the value changes. Using a
// fresh value on every open therefore covers both listener orderings, and
// avoids node handles, which no longer resolve from the new architecture's
// host instances.
targetRef.current += 1;
animatedKeyboardState.set(state => ({ ...state, target: targetRef.current }));
});

return () => subscription.remove();
}, [animatedKeyboardState]);

return null;
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<package xmlns="http://www.mendix.com/package/1.0/">
<clientModule name="BottomSheet" version="5.3.2" xmlns="http://www.mendix.com/clientModule/1.0/">
<clientModule name="BottomSheet" version="5.3.3" xmlns="http://www.mendix.com/clientModule/1.0/">
<widgetFiles>
<widgetFile path="BottomSheet.xml" />
</widgetFiles>
Expand Down
Loading