diff --git a/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt b/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt index bbc2246f62..2122d35b73 100644 --- a/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt +++ b/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt @@ -1,5 +1,6 @@ package com.reactnativekeyboardcontroller +import com.facebook.react.bridge.ReadableArray import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.uimanager.annotations.ReactProp @@ -56,6 +57,23 @@ class KeyboardControllerViewManager : ) = manager.setEnabled(view as EdgeToEdgeReactViewGroup, value) // endregion + // region Commands + override fun receiveCommand( + root: ReactViewGroup, + commandId: String, + args: ReadableArray?, + ) { + when (commandId) { + "synchronizeFocusedInputLayout" -> synchronizeFocusedInputLayout(root) + else -> super.receiveCommand(root, commandId, args) + } + } + + override fun synchronizeFocusedInputLayout(view: ReactViewGroup) { + manager.synchronizeFocusedInputLayout(view as EdgeToEdgeReactViewGroup) + } + // endregion + // region Getters override fun getExportedCustomDirectEventTypeConstants(): MutableMap = manager.getExportedCustomDirectEventTypeConstants() diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt index 81cd40c4cb..f1220f53d8 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt @@ -109,7 +109,7 @@ class KeyboardAnimationCallback( } } } - private var layoutObserver: FocusedInputObserver? = null + internal var layoutObserver: FocusedInputObserver? = null init { require(config.persistentInsetTypes and config.deferredInsetTypes == 0) { diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt b/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt index 716fa002ef..7f65c22eed 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt @@ -1,11 +1,13 @@ package com.reactnativekeyboardcontroller.managers +import com.facebook.react.bridge.Arguments import com.facebook.react.common.MapBuilder import com.facebook.react.uimanager.ThemedReactContext import com.reactnativekeyboardcontroller.events.FocusedInputLayoutChangedEvent import com.reactnativekeyboardcontroller.events.FocusedInputSelectionChangedEvent import com.reactnativekeyboardcontroller.events.FocusedInputTextChangedEvent import com.reactnativekeyboardcontroller.events.KeyboardTransitionEvent +import com.reactnativekeyboardcontroller.extensions.emitEvent import com.reactnativekeyboardcontroller.listeners.WindowDimensionListener import com.reactnativekeyboardcontroller.views.EdgeToEdgeReactViewGroup @@ -25,6 +27,11 @@ class KeyboardControllerViewManagerImpl { listener = null } + fun synchronizeFocusedInputLayout(view: EdgeToEdgeReactViewGroup) { + view.callback?.layoutObserver?.syncUpLayout() + view.reactContext.emitEvent("KeyboardController::layoutDidSynchronize", Arguments.createMap()) + } + fun setEnabled( view: EdgeToEdgeReactViewGroup, enabled: Boolean, diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt b/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt index 46b01278b1..003890b434 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt @@ -38,7 +38,7 @@ object EdgeToEdgeViewRegistry { @Suppress("detekt:TooManyFunctions") @SuppressLint("ViewConstructor") class EdgeToEdgeReactViewGroup( - private val reactContext: ThemedReactContext, + val reactContext: ThemedReactContext, ) : ReactViewGroup(reactContext) { // props private var isStatusBarTranslucent = false @@ -58,7 +58,7 @@ class EdgeToEdgeReactViewGroup( // internal class members private var eventView: ReactViewGroup? = null private var wasMounted = false - private var callback: KeyboardAnimationCallback? = null + internal var callback: KeyboardAnimationCallback? = null private val config = KeyboardAnimationCallbackConfig( persistentInsetTypes = WindowInsetsCompat.Type.systemBars(), diff --git a/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt b/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt index 43c3ddf94f..2ecfbd22a4 100644 --- a/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt +++ b/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardControllerViewManager.kt @@ -1,5 +1,6 @@ package com.reactnativekeyboardcontroller +import com.facebook.react.bridge.ReadableArray import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.views.view.ReactViewGroup @@ -58,6 +59,21 @@ class KeyboardControllerViewManager : ReactViewManager() { } // endregion + // region Commands + override fun receiveCommand( + root: ReactViewGroup, + commandId: String, + args: ReadableArray?, + ) { + when (commandId) { + "synchronizeFocusedInputLayout" -> { + manager.synchronizeFocusedInputLayout(root as EdgeToEdgeReactViewGroup) + } + else -> super.receiveCommand(root, commandId, args) + } + } + //endregion + // region Getters override fun getExportedCustomDirectEventTypeConstants(): MutableMap = manager.getExportedCustomDirectEventTypeConstants() diff --git a/docs/docs/api/components/keyboard-aware-scroll-view.mdx b/docs/docs/api/components/keyboard-aware-scroll-view.mdx index 36028e8676..d2199be7b7 100644 --- a/docs/docs/api/components/keyboard-aware-scroll-view.mdx +++ b/docs/docs/api/components/keyboard-aware-scroll-view.mdx @@ -244,6 +244,14 @@ export function Example() { } ``` +## Methods + +### `assureFocusedInputVisible` + +A method that assures that focused input is visible and not obscured by keyboard or other elements. + +You may want to call it, when layout inside `ScrollView` changes (for example validation message appears or disappears and it shifts position of focused input). + ## Example ```tsx diff --git a/docs/docs/api/hooks/input/use-reanimated-focused-input.md b/docs/docs/api/hooks/input/use-reanimated-focused-input.md index fe51eeab12..77f308fe80 100644 --- a/docs/docs/api/hooks/input/use-reanimated-focused-input.md +++ b/docs/docs/api/hooks/input/use-reanimated-focused-input.md @@ -26,7 +26,7 @@ Hook will update its value in next cases: The value from `useReanimatedFocusedInput` will be always updated before keyboard events, so you can safely read values in `onStart` handler and be sure they are up-to-date. ::: -## Event structure +## `input` The `input` property from this hook is returned as `SharedValue`. The returned data has next structure: @@ -47,10 +47,14 @@ type FocusedInputLayoutChangedEvent = { }; ``` +### `update` + +To update the focused input, use `update` function. Thus you can query the position on demand from JS thread. + ## Example ```tsx -const { input } = useReanimatedFocusedInput(); +const { input, update } = useReanimatedFocusedInput(); ``` Also have a look on [example](https://github.com/kirillzyusko/react-native-keyboard-controller/tree/main/example) app for more comprehensive usage. diff --git a/e2e/kit/016-aware-scroll-view-with-sticky-view.e2e.ts b/e2e/kit/016-aware-scroll-view-with-sticky-view.e2e.ts index f125958ae6..45169ca186 100644 --- a/e2e/kit/016-aware-scroll-view-with-sticky-view.e2e.ts +++ b/e2e/kit/016-aware-scroll-view-with-sticky-view.e2e.ts @@ -1,12 +1,18 @@ import { expectBitmapsToBeEqual } from "./asserts"; import { scrollDownUntilElementIsVisible, + tap, waitAndTap, waitForExpect, } from "./helpers"; const BLINKING_CURSOR = 0.35; +const closeKeyboard = async () => { + // tap outside to close a keyboard + await tap("aware_scroll_sticky_view_scroll_container", { x: 0, y: 100 }); +}; + describe("AwareScrollView with StickyView test cases", () => { it("should push input above keyboard on focus", async () => { await waitAndTap("aware_scroll_view_sticky_footer"); @@ -32,4 +38,35 @@ describe("AwareScrollView with StickyView test cases", () => { ); }); }); + + it("should react on `bottomOffset` change even if input is not visible", async () => { + await scrollDownUntilElementIsVisible( + "aware_scroll_sticky_view_scroll_container", + "TextInput#9", + { x: 0, y: 0.2, checkScrollViewVisibility: false }, + ); + await waitAndTap("toggle_height"); + await waitForExpect(async () => { + await expectBitmapsToBeEqual( + "AwareScrollViewWithStickyViewFirstInputFocused", + BLINKING_CURSOR, + ); + }); + }); + + it("shouldn't scroll a scroll view when focusing input inside sticky view", async () => { + await closeKeyboard(); + await element(by.id("aware_scroll_sticky_view_scroll_container")).swipe( + "down", + "fast", + 1, + ); + await waitAndTap("Amount"); + await waitForExpect(async () => { + await expectBitmapsToBeEqual( + "AwareScrollViewWithStickyViewStickyInputFocused", + BLINKING_CURSOR, + ); + }); + }); }); diff --git a/e2e/kit/assets/android/e2e_emulator_28/AwareScrollViewWithStickyViewStickyInputFocused.png b/e2e/kit/assets/android/e2e_emulator_28/AwareScrollViewWithStickyViewStickyInputFocused.png new file mode 100644 index 0000000000..3398060664 Binary files /dev/null and b/e2e/kit/assets/android/e2e_emulator_28/AwareScrollViewWithStickyViewStickyInputFocused.png differ diff --git a/e2e/kit/assets/android/e2e_emulator_31/AwareScrollViewWithStickyViewStickyInputFocused.png b/e2e/kit/assets/android/e2e_emulator_31/AwareScrollViewWithStickyViewStickyInputFocused.png new file mode 100644 index 0000000000..ea841dd376 Binary files /dev/null and b/e2e/kit/assets/android/e2e_emulator_31/AwareScrollViewWithStickyViewStickyInputFocused.png differ diff --git a/e2e/kit/assets/ios/iPhone 13 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png b/e2e/kit/assets/ios/iPhone 13 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png new file mode 100644 index 0000000000..cc77177407 Binary files /dev/null and b/e2e/kit/assets/ios/iPhone 13 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png differ diff --git a/e2e/kit/assets/ios/iPhone 14 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png b/e2e/kit/assets/ios/iPhone 14 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png new file mode 100644 index 0000000000..35743e347d Binary files /dev/null and b/e2e/kit/assets/ios/iPhone 14 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png differ diff --git a/e2e/kit/assets/ios/iPhone 15 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png b/e2e/kit/assets/ios/iPhone 15 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png new file mode 100644 index 0000000000..af2ad4ed1a Binary files /dev/null and b/e2e/kit/assets/ios/iPhone 15 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png differ diff --git a/e2e/kit/assets/ios/iPhone 16 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png b/e2e/kit/assets/ios/iPhone 16 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png new file mode 100644 index 0000000000..26cc0640b0 Binary files /dev/null and b/e2e/kit/assets/ios/iPhone 16 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png differ diff --git a/e2e/kit/assets/ios/iPhone 17 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png b/e2e/kit/assets/ios/iPhone 17 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png new file mode 100644 index 0000000000..0e164c5f7f Binary files /dev/null and b/e2e/kit/assets/ios/iPhone 17 Pro/AwareScrollViewWithStickyViewStickyInputFocused.png differ diff --git a/e2e/kit/helpers/actions/index.ts b/e2e/kit/helpers/actions/index.ts index 01fe44433e..84391b492b 100644 --- a/e2e/kit/helpers/actions/index.ts +++ b/e2e/kit/helpers/actions/index.ts @@ -121,12 +121,23 @@ export const switchToEmojiKeyboard = async () => { export const scrollDownUntilElementIsVisible = async ( scrollViewId: string, elementId: string, + { + x, + y, + checkScrollViewVisibility, + }: { x: number; y: number; checkScrollViewVisibility: boolean } = { + x: NaN, + y: 0.5, + checkScrollViewVisibility: true, + }, ): Promise => { - await waitForElementById(scrollViewId, TIMEOUT_FOR_LONG_OPERATIONS); + if (checkScrollViewVisibility) { + await waitForElementById(scrollViewId, TIMEOUT_FOR_LONG_OPERATIONS); + } await waitFor(element(by.id(elementId))) .toBeVisible() .whileElement(by.id(scrollViewId)) - .scroll(100, "down", NaN, 0.5); + .scroll(100, "down", x, y); }; export const scrollUpUntilElementIsBarelyVisible = async ( diff --git a/ios/KeyboardControllerModule.mm b/ios/KeyboardControllerModule.mm index 6f9fbee82a..b760887d3a 100644 --- a/ios/KeyboardControllerModule.mm +++ b/ios/KeyboardControllerModule.mm @@ -127,6 +127,7 @@ - (void)sendEvent:(NSString *)name body:(id)body @"KeyboardController::keyboardDidHide", // focused input @"KeyboardController::focusDidSet", + @"KeyboardController::layoutDidSynchronize", // window dimensions @"KeyboardController::windowDidResize", ]; diff --git a/ios/observers/FocusedInputObserver.swift b/ios/observers/FocusedInputObserver.swift index b208ea9dda..f6b9226a30 100644 --- a/ios/observers/FocusedInputObserver.swift +++ b/ios/observers/FocusedInputObserver.swift @@ -165,7 +165,7 @@ public class FocusedInputObserver: NSObject { dispatchEventToJS(data: noFocusedInputEvent) } - @objc func syncUpLayout() { + @objc public func syncUpLayout() { let responder = currentResponder as UIResponder? let focusedInput = currentInput let globalFrame = focusedInput?.globalFrame diff --git a/ios/views/KeyboardControllerView.mm b/ios/views/KeyboardControllerView.mm index 07d63897b3..334c9f5309 100644 --- a/ios/views/KeyboardControllerView.mm +++ b/ios/views/KeyboardControllerView.mm @@ -41,6 +41,17 @@ @implementation KeyboardControllerView { CGSize _lastScreenSize; } +- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args +{ + RCTKeyboardControllerViewHandleCommand(self, commandName, args); +} + +- (void)synchronizeFocusedInputLayout +{ + [inputObserver syncUpLayout]; + [KeyboardController.shared sendEvent:@"KeyboardController::layoutDidSynchronize" body:nil]; +} + + (ComponentDescriptorProvider)componentDescriptorProvider { return concreteComponentDescriptorProvider(); diff --git a/ios/views/KeyboardControllerViewManager.mm b/ios/views/KeyboardControllerViewManager.mm index 28a4c513b9..e4f4ad9780 100644 --- a/ios/views/KeyboardControllerViewManager.mm +++ b/ios/views/KeyboardControllerViewManager.mm @@ -16,4 +16,7 @@ @interface RCT_EXTERN_MODULE (KeyboardControllerViewManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(onFocusedInputTextChanged, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onFocusedInputSelectionChanged, RCTDirectEventBlock); +// commands +RCT_EXTERN_METHOD(synchronizeFocusedInputLayout : (nonnull NSNumber *)reactTag); + @end diff --git a/ios/views/KeyboardControllerViewManager.swift b/ios/views/KeyboardControllerViewManager.swift index cc28ac356f..49ec60ead5 100644 --- a/ios/views/KeyboardControllerViewManager.swift +++ b/ios/views/KeyboardControllerViewManager.swift @@ -7,12 +7,24 @@ class KeyboardControllerViewManager: RCTViewManager { override func view() -> (KeyboardControllerView) { return KeyboardControllerView(frame: CGRect.zero, bridge: bridge) } + + @objc(synchronizeFocusedInputLayout:) + func synchronizeFocusedInputLayout(_ reactTag: NSNumber) { + bridge.uiManager.addUIBlock { _, viewRegistry in + guard let view = viewRegistry?[reactTag] as? KeyboardControllerView else { + return + } + + view.inputObserver?.syncUpLayout() + KeyboardController.shared()?.sendEvent("KeyboardController::layoutDidSynchronize", body: nil) + } + } } class KeyboardControllerView: UIView { // internal variables private var keyboardObserver: KeyboardMovementObserver? - private var inputObserver: FocusedInputObserver? + var inputObserver: FocusedInputObserver? private var eventDispatcher: RCTEventDispatcherProtocol private var bridge: RCTBridge // internal state diff --git a/src/animated.tsx b/src/animated.tsx index c68edbb49d..cbb8f8f780 100644 --- a/src/animated.tsx +++ b/src/animated.tsx @@ -1,5 +1,11 @@ /* eslint react/jsx-sort-props: off */ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Animated, Platform, StyleSheet } from "react-native"; import { controlEdgeToEdgeValues, @@ -7,7 +13,11 @@ import { } from "react-native-is-edge-to-edge"; import Reanimated, { useSharedValue } from "react-native-reanimated"; -import { KeyboardControllerView } from "./bindings"; +import { + FocusedInputEvents, + KeyboardControllerView, + KeyboardControllerViewCommands, +} from "./bindings"; import { KeyboardContext } from "./context"; import { useAnimatedValue, useEventHandlerRegistration } from "./internal"; import { KeyboardController } from "./module"; @@ -117,7 +127,7 @@ export const KeyboardProvider = (props: KeyboardProviderProps) => { preload = true, } = props; // ref - const viewTagRef = useRef>(null); + const viewRef = useRef>(null); // state const [enabled, setEnabled] = useState(initiallyEnabled); // animated values @@ -127,8 +137,23 @@ export const KeyboardProvider = (props: KeyboardProviderProps) => { const progressSV = useSharedValue(0); const heightSV = useSharedValue(0); const layout = useSharedValue(null); - const setKeyboardHandlers = useEventHandlerRegistration(viewTagRef); - const setInputHandlers = useEventHandlerRegistration(viewTagRef); + const setKeyboardHandlers = useEventHandlerRegistration(viewRef); + const setInputHandlers = useEventHandlerRegistration(viewRef); + const update = useCallback(async () => { + KeyboardControllerViewCommands.synchronizeFocusedInputLayout( + viewRef.current, + ); + + await new Promise((resolve) => { + const subscription = FocusedInputEvents.addListener( + "layoutDidSynchronize", + () => { + subscription.remove(); + resolve(null); + }, + ); + }); + }, []); // memo const context = useMemo( () => ({ @@ -136,6 +161,7 @@ export const KeyboardProvider = (props: KeyboardProviderProps) => { animated: { progress: progress, height: Animated.multiply(height, -1) }, reanimated: { progress: progressSV, height: heightSV }, layout, + update, setKeyboardHandlers, setInputHandlers, setEnabled, @@ -232,7 +258,7 @@ export const KeyboardProvider = (props: KeyboardProviderProps) => { return ( = - require("./specs/KeyboardControllerViewNativeComponent").default; + KeyboardControllerViewNativeComponentModule.default; +export const KeyboardControllerViewCommands = + KeyboardControllerViewNativeComponentModule.Commands; export const KeyboardGestureArea: React.FC = (Platform.OS === "android" && Platform.Version >= 30) || Platform.OS === "ios" ? require("./specs/KeyboardGestureAreaNativeComponent").default diff --git a/src/bindings.ts b/src/bindings.ts index 30cb973123..2b0c01ce85 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -53,6 +53,11 @@ export const WindowDimensionsEvents: WindowDimensionsEventsModule = { */ export const KeyboardControllerView = View as unknown as React.FC; +export const KeyboardControllerViewCommands = { + synchronizeFocusedInputLayout: ( + _ref: React.Component | null, + ) => {}, +}; /** * A view that defines a region on the screen, where gestures will control the keyboard position. * diff --git a/src/components/KeyboardAwareScrollView/index.tsx b/src/components/KeyboardAwareScrollView/index.tsx index 70b18299b4..f053104197 100644 --- a/src/components/KeyboardAwareScrollView/index.tsx +++ b/src/components/KeyboardAwareScrollView/index.tsx @@ -1,4 +1,10 @@ -import React, { forwardRef, useCallback, useEffect, useMemo } from "react"; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, +} from "react"; import Reanimated, { clamp, interpolate, @@ -44,6 +50,9 @@ export type KeyboardAwareScrollViewProps = { /** Custom component for `ScrollView`. Default is `ScrollView`. */ ScrollViewComponent?: React.ComponentType; } & ScrollViewProps; +export type KeyboardAwareScrollViewRef = { + assureFocusedInputVisible: () => void; +} & ScrollView; // Everything begins from `onStart` handler. This handler is called every time, // when keyboard changes its size or when focused `TextInput` was changed. In @@ -99,7 +108,7 @@ export type KeyboardAwareScrollViewProps = { * ``` */ const KeyboardAwareScrollView = forwardRef< - ScrollView, + KeyboardAwareScrollViewRef, React.PropsWithChildren >( ( @@ -117,6 +126,7 @@ const KeyboardAwareScrollView = forwardRef< ref, ) => { const scrollViewAnimatedRef = useAnimatedRef(); + const scrollViewRef = React.useRef(null); const scrollViewTarget = useSharedValue(null); const scrollPosition = useSharedValue(0); const position = useScrollViewOffset(scrollViewAnimatedRef); @@ -126,7 +136,7 @@ const KeyboardAwareScrollView = forwardRef< const tag = useSharedValue(-1); const initialKeyboardSize = useSharedValue(0); const scrollBeforeKeyboardMovement = useSharedValue(0); - const { input } = useReanimatedFocusedInput(); + const { input, update } = useReanimatedFocusedInput(); const layout = useSharedValue(null); const lastSelection = useSharedValue(null); @@ -134,11 +144,7 @@ const KeyboardAwareScrollView = forwardRef< const { height } = useWindowDimensions(); const onRef = useCallback((assignedRef: Reanimated.ScrollView) => { - if (typeof ref === "function") { - ref(assignedRef); - } else if (ref) { - ref.current = assignedRef; - } + scrollViewRef.current = assignedRef; scrollViewAnimatedRef(assignedRef); }, []); @@ -392,10 +398,36 @@ const KeyboardAwareScrollView = forwardRef< [maybeScroll, disableScrollOnKeyboardHide, syncKeyboardFrame], ); + const synchronize = useCallback(async () => { + await update(); + + runOnUI(() => { + "worklet"; + + scrollFromCurrentPosition(); + })(); + }, [update, scrollFromCurrentPosition]); + + useImperativeHandle( + ref, + () => { + const scrollView = scrollViewRef.current; + + const existingMethods = scrollView ? { ...scrollView } : {}; + + return { + ...existingMethods, + + assureFocusedInputVisible: () => { + synchronize(); + }, + } as KeyboardAwareScrollViewRef; + }, + [synchronize], + ); + useEffect(() => { - runOnUI(performScrollWithPositionRestoration)( - scrollBeforeKeyboardMovement.value, - ); + synchronize(); }, [bottomOffset]); useAnimatedReaction( diff --git a/src/components/index.ts b/src/components/index.ts index e868519b32..2a6983a1cb 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,5 +7,8 @@ export { } from "./KeyboardToolbar"; export type { KeyboardAvoidingViewProps } from "./KeyboardAvoidingView"; export type { KeyboardStickyViewProps } from "./KeyboardStickyView"; -export type { KeyboardAwareScrollViewProps } from "./KeyboardAwareScrollView"; +export type { + KeyboardAwareScrollViewProps, + KeyboardAwareScrollViewRef, +} from "./KeyboardAwareScrollView"; export type { KeyboardToolbarProps } from "./KeyboardToolbar"; diff --git a/src/context.ts b/src/context.ts index 4a7bc46c8c..2b4f2e076b 100644 --- a/src/context.ts +++ b/src/context.ts @@ -35,6 +35,8 @@ export type KeyboardAnimationContext = { reanimated: ReanimatedContext; /** Layout of the focused `TextInput` represented as `SharedValue`. */ layout: SharedValue; + /** Method for updating info about focused input layout. */ + update: () => Promise; /** Method for setting workletized keyboard handlers. */ setKeyboardHandlers: ( handlers: EventHandlerProcessed, @@ -71,6 +73,7 @@ const defaultContext: KeyboardAnimationContext = { height: DEFAULT_SHARED_VALUE, }, layout: DEFAULT_LAYOUT, + update: Promise.resolve, setKeyboardHandlers: NESTED_NOOP, setInputHandlers: NESTED_NOOP, setEnabled: NOOP, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ff284f8f35..5e458ea525 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -236,7 +236,10 @@ export function useKeyboardController() { export function useReanimatedFocusedInput() { const context = useKeyboardContext(); - return { input: context.layout }; + return { + input: context.layout, + update: context.update, + }; } /** diff --git a/src/index.ts b/src/index.ts index 9093708b5e..f7704b8000 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ export type { KeyboardAvoidingViewProps, KeyboardStickyViewProps, KeyboardAwareScrollViewProps, + KeyboardAwareScrollViewRef, KeyboardToolbarProps, } from "./components"; export { OverKeyboardView, KeyboardExtender } from "./views"; diff --git a/src/specs/KeyboardControllerViewNativeComponent.ts b/src/specs/KeyboardControllerViewNativeComponent.ts index db13802724..443caa53eb 100644 --- a/src/specs/KeyboardControllerViewNativeComponent.ts +++ b/src/specs/KeyboardControllerViewNativeComponent.ts @@ -1,3 +1,4 @@ +import codegenNativeCommands from "react-native/Libraries/Utilities/codegenNativeCommands"; import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; import type { HostComponent } from "react-native"; @@ -66,6 +67,16 @@ export interface NativeProps extends ViewProps { onFocusedInputSelectionChanged?: DirectEventHandler; } +interface NativeCommands { + synchronizeFocusedInputLayout: ( + viewRef: React.ElementRef>, + ) => void; +} + +export const Commands: NativeCommands = codegenNativeCommands({ + supportedCommands: ["synchronizeFocusedInputLayout"], +}); + export default codegenNativeComponent("KeyboardControllerView", { interfaceOnly: true, }) as HostComponent; diff --git a/src/types/internal.ts b/src/types/internal.ts index 8f67dd0b23..500d2f66cb 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -1,6 +1,8 @@ import type { EmitterSubscription } from "react-native"; -export type FocusedInputAvailableEvents = "focusDidSet"; +export type FocusedInputAvailableEvents = + | "focusDidSet" + | "layoutDidSynchronize"; export type FocusedInputEventData = { current: number; count: number;