-
Notifications
You must be signed in to change notification settings - Fork 23
[MOO-2480] resolve keyboard covering inputs in iOS modal bottom sheet #634
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" } : {}; | ||
|
|
||
| interface CustomModalSheetProps { | ||
| triggerAttribute?: EditableValue<boolean>; | ||
|
|
@@ -95,7 +106,9 @@ export const CustomModalSheet = (props: CustomModalSheetProps): ReactElement => | |
| backgroundStyle={props.styles.container} | ||
| handleComponent={null} | ||
| handleStyle={{ display: "none" }} | ||
| {...keyboardProps} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| > | ||
| <SheetKeyboardTracker /> | ||
| <BottomSheetScrollView style={[{ flex: 1 }]} contentContainerStyle={{ paddingBottom: 16 }}> | ||
| {props.content} | ||
| </BottomSheetScrollView> | ||
|
|
||
| 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", () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| }; | ||
There was a problem hiding this comment.
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.