diff --git a/packages/pluggableWidgets/bottom-sheet-native/CHANGELOG.md b/packages/pluggableWidgets/bottom-sheet-native/CHANGELOG.md index 2a35ff1b8..2a476bfd1 100644 --- a/packages/pluggableWidgets/bottom-sheet-native/CHANGELOG.md +++ b/packages/pluggableWidgets/bottom-sheet-native/CHANGELOG.md @@ -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. diff --git a/packages/pluggableWidgets/bottom-sheet-native/package.json b/packages/pluggableWidgets/bottom-sheet-native/package.json index 565b25da9..2add18485 100644 --- a/packages/pluggableWidgets/bottom-sheet-native/package.json +++ b/packages/pluggableWidgets/bottom-sheet-native/package.json @@ -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", diff --git a/packages/pluggableWidgets/bottom-sheet-native/src/__tests__/SheetKeyboardTracker.spec.tsx b/packages/pluggableWidgets/bottom-sheet-native/src/__tests__/SheetKeyboardTracker.spec.tsx new file mode 100644 index 000000000..4ecfab74c --- /dev/null +++ b/packages/pluggableWidgets/bottom-sheet-native/src/__tests__/SheetKeyboardTracker.spec.tsx @@ -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; + 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(); + + 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(); + + showKeyboard(); + + expect(animatedKeyboardState.get().height).toBe(336); + }); + + it("reports a new target on every open, so a cached event is always replayed", () => { + render(); + + 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(); + + showKeyboard(); + + expect(animatedKeyboardState.get().target).toBeTruthy(); + }); + + it("does not subscribe on Android, where the OS already moves the input into view", () => { + setPlatform("android"); + + render(); + + expect(Keyboard.addListener).not.toHaveBeenCalled(); + }); + + it("unsubscribes on unmount", () => { + const { unmount } = render(); + + unmount(); + + expect(removeListener).toHaveBeenCalled(); + }); +}); diff --git a/packages/pluggableWidgets/bottom-sheet-native/src/components/CustomModalSheet.tsx b/packages/pluggableWidgets/bottom-sheet-native/src/components/CustomModalSheet.tsx index 2f0fccea2..c2d99277e 100644 --- a/packages/pluggableWidgets/bottom-sheet-native/src/components/CustomModalSheet.tsx +++ b/packages/pluggableWidgets/bottom-sheet-native/src/components/CustomModalSheet.tsx @@ -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 = + Platform.OS === "ios" ? { keyboardBehavior: "interactive", keyboardBlurBehavior: "restore" } : {}; interface CustomModalSheetProps { triggerAttribute?: EditableValue; @@ -95,7 +106,9 @@ export const CustomModalSheet = (props: CustomModalSheetProps): ReactElement => backgroundStyle={props.styles.container} handleComponent={null} handleStyle={{ display: "none" }} + {...keyboardProps} > + {props.content} diff --git a/packages/pluggableWidgets/bottom-sheet-native/src/components/SheetKeyboardTracker.tsx b/packages/pluggableWidgets/bottom-sheet-native/src/components/SheetKeyboardTracker.tsx new file mode 100644 index 000000000..2247a61be --- /dev/null +++ b/packages/pluggableWidgets/bottom-sheet-native/src/components/SheetKeyboardTracker.tsx @@ -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", () => { + // 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; +}; diff --git a/packages/pluggableWidgets/bottom-sheet-native/src/package.xml b/packages/pluggableWidgets/bottom-sheet-native/src/package.xml index ad216156a..93fd3e5f0 100644 --- a/packages/pluggableWidgets/bottom-sheet-native/src/package.xml +++ b/packages/pluggableWidgets/bottom-sheet-native/src/package.xml @@ -1,6 +1,6 @@ - +