diff --git a/FabricExample/__tests__/focused-input.spec.tsx b/FabricExample/__tests__/focused-input.spec.tsx new file mode 100644 index 0000000000..20beff6435 --- /dev/null +++ b/FabricExample/__tests__/focused-input.spec.tsx @@ -0,0 +1,62 @@ +import '@testing-library/jest-native/extend-expect'; +import React from 'react'; +import Reanimated, { useAnimatedStyle } from 'react-native-reanimated'; +import { render } from '@testing-library/react-native'; + +import { useReanimatedFocusedInput } from 'react-native-keyboard-controller'; + +function RectangleWithFocusedInputLayout() { + const { input } = useReanimatedFocusedInput(); + const style = useAnimatedStyle( + () => { + const layout = input.value?.layout; + + return { + top: layout?.y, + left: layout?.x, + height: layout?.height, + width: layout?.width, + }; + }, + [] + ); + + return ; +} + +describe('`useReanimatedFocusedInput` mocking', () => { + it('should have different styles depends on `useReanimatedFocusedInput`', () => { + const { getByTestId, update } = render(); + + expect(getByTestId('view')).toHaveStyle({ + top: 0, + left: 0, + width: 200, + height: 40, + }); + + (useReanimatedFocusedInput as jest.Mock).mockReturnValue({ + input: { + value: { + target: 2, + layout: { + x: 10, + y: 100, + width: 190, + height: 80, + absoluteX: 100, + absoluteY: 200, + }, + }, + } + }); + update(); + + expect(getByTestId('view')).toHaveStyle({ + top: 100, + left: 10, + width: 190, + height: 80, + }); + }); +}); diff --git a/FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx b/FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx index 7d9785bec3..6019acf6ea 100644 --- a/FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx +++ b/FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx @@ -1,9 +1,10 @@ import React, { FC } from 'react'; import { ScrollViewProps, useWindowDimensions } from 'react-native'; +import { FocusedInputLayoutChangedEvent, useReanimatedFocusedInput } from 'react-native-keyboard-controller'; import Reanimated, { - MeasuredDimensions, interpolate, scrollTo, + useAnimatedReaction, useAnimatedRef, useAnimatedScrollHandler, useAnimatedStyle, @@ -11,12 +12,9 @@ import Reanimated, { useWorkletCallback, } from 'react-native-reanimated'; import { useSmoothKeyboardHandler } from './useSmoothKeyboardHandler'; -import { AwareScrollViewProvider, useAwareScrollView } from './context'; const BOTTOM_OFFSET = 50; -type KeyboardAwareScrollViewProps = ScrollViewProps; - /** * Everything begins from `onStart` handler. This handler is called every time, * when keyboard changes its size or when focused `TextInput` was changed. In @@ -55,22 +53,21 @@ type KeyboardAwareScrollViewProps = ScrollViewProps; * +============================+ +============================+ +=====================================+ * */ -const KeyboardAwareScrollView: FC = ({ +const KeyboardAwareScrollView: FC = ({ children, ...rest }) => { const scrollViewAnimatedRef = useAnimatedRef(); const scrollPosition = useSharedValue(0); const position = useSharedValue(0); - const layout = useSharedValue(null); - const fakeViewHeight = useSharedValue(0); const keyboardHeight = useSharedValue(0); const tag = useSharedValue(-1); const initialKeyboardSize = useSharedValue(0); const scrollBeforeKeyboardMovement = useSharedValue(0); + const { input } = useReanimatedFocusedInput(); + const layout = useSharedValue(null); const { height } = useWindowDimensions(); - const { measure } = useAwareScrollView(); const onScroll = useAnimatedScrollHandler( { @@ -84,10 +81,9 @@ const KeyboardAwareScrollView: FC = ({ /** * Function that will scroll a ScrollView as keyboard gets moving */ - const maybeScroll = useWorkletCallback((e: number, animated = false) => { - fakeViewHeight.value = e; + const maybeScroll = useWorkletCallback((e: number, animated: boolean = false) => { const visibleRect = height - keyboardHeight.value; - const point = (layout.value?.pageY || 0) + (layout.value?.height || 0); + const point = (layout.value?.layout.absoluteY || 0) + (layout.value?.layout.height || 0); if (visibleRect - point <= BOTTOM_OFFSET) { const interpolatedScrollTo = interpolate( @@ -98,7 +94,11 @@ const KeyboardAwareScrollView: FC = ({ const targetScrollY = Math.max(interpolatedScrollTo, 0) + scrollPosition.value; scrollTo(scrollViewAnimatedRef, 0, targetScrollY, animated); + + return interpolatedScrollTo; } + + return 0; }, []); useSmoothKeyboardHandler( @@ -110,6 +110,8 @@ const KeyboardAwareScrollView: FC = ({ keyboardHeight.value !== e.height && e.height > 0; const keyboardWillAppear = e.height > 0 && keyboardHeight.value === 0; const keyboardWillHide = e.height === 0; + const focusWasChanged = (tag.value !== e.target && e.target !== -1) || keyboardWillChangeSize; + if (keyboardWillChangeSize) { initialKeyboardSize.value = keyboardHeight.value; } @@ -120,7 +122,7 @@ const KeyboardAwareScrollView: FC = ({ scrollPosition.value = scrollBeforeKeyboardMovement.value; } - if (keyboardWillAppear || keyboardWillChangeSize) { + if (keyboardWillAppear || keyboardWillChangeSize || focusWasChanged) { // persist scroll value scrollPosition.value = position.value; // just persist height - later will be used in interpolation @@ -128,16 +130,20 @@ const KeyboardAwareScrollView: FC = ({ } // focus was changed - if (tag.value !== e.target || keyboardWillChangeSize) { + if (focusWasChanged) { tag.value = e.target; - if (tag.value !== -1) { - // save position of focused text input when keyboard starts to move - layout.value = measure(e.target); - // save current scroll position - when keyboard will hide we'll reuse - // this value to achieve smooth hide effect - scrollBeforeKeyboardMovement.value = position.value; - } + // save position of focused text input when keyboard starts to move + layout.value = input.value; + // save current scroll position - when keyboard will hide we'll reuse + // this value to achieve smooth hide effect + scrollBeforeKeyboardMovement.value = position.value; + } + + if (focusWasChanged && !keyboardWillAppear) { + // update position on scroll value, so `onEnd` handler + // will pick up correct values + position.value += maybeScroll(e.height, true); } }, onMove: (e) => { @@ -150,24 +156,24 @@ const KeyboardAwareScrollView: FC = ({ keyboardHeight.value = e.height; scrollPosition.value = position.value; - - if (e.target !== -1 && e.height !== 0) { - const prevLayout = layout.value; - // just be sure, that view is no overlapped (i.e. focus changed) - layout.value = measure(e.target); - maybeScroll(e.height, true); - // do layout substitution back to assure there will be correct - // back transition when keyboard hides - layout.value = prevLayout; - } }, }, [height] ); + useAnimatedReaction(() => input.value, (current, previous) => { + if (current?.target === previous?.target && current?.layout.height !== previous?.layout.height) { + const prevLayout = layout.value; + + layout.value = input.value; + scrollPosition.value += maybeScroll(keyboardHeight.value, true); + layout.value = prevLayout; + } + }, []); + const view = useAnimatedStyle( () => ({ - height: fakeViewHeight.value, + paddingBottom: keyboardHeight.value, }), [] ); @@ -179,16 +185,11 @@ const KeyboardAwareScrollView: FC = ({ onScroll={onScroll} scrollEventThrottle={16} > - {children} - + + {children} + ); }; -export default function (props: KeyboardAwareScrollViewProps) { - return ( - - - - ); -} +export default KeyboardAwareScrollView; diff --git a/FabricExample/src/screens/Examples/AwareScrollView/TextInput.tsx b/FabricExample/src/screens/Examples/AwareScrollView/TextInput.tsx index ecbcba1f0b..00846b8f81 100644 --- a/FabricExample/src/screens/Examples/AwareScrollView/TextInput.tsx +++ b/FabricExample/src/screens/Examples/AwareScrollView/TextInput.tsx @@ -1,29 +1,32 @@ import React from 'react'; -import { TextInputProps, TextInput as TextInputRN } from 'react-native'; -import { randomColor } from '../../../utils'; -import { useAwareScrollView } from './context'; - -const TextInput = React.forwardRef((props: TextInputProps, forwardRef) => { - const { onRef } = useAwareScrollView(); +import { StyleSheet, TextInputProps, TextInput as TextInputRN } from 'react-native'; +const TextInput = (props: TextInputProps) => { return ( { - onRef(ref); - if (typeof forwardRef === 'function') { - forwardRef(ref); - } - }} - placeholderTextColor="black" - style={{ - width: '100%', - height: 50, - backgroundColor: randomColor(), - marginTop: 50, - }} + placeholderTextColor="#6c6c6c" + style={styles.container} + multiline + numberOfLines={10} {...props} + placeholder={`${props.placeholder} (${props.keyboardType === 'default' ? 'text' : 'numeric'})`} /> ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + minHeight: 50, + maxHeight: 200, + marginBottom: 50, + borderColor: 'black', + borderWidth: 2, + marginRight: 160, + borderRadius: 10, + color: 'black', + paddingHorizontal: 12, + }, }); export default TextInput; diff --git a/FabricExample/src/screens/Examples/AwareScrollView/context.tsx b/FabricExample/src/screens/Examples/AwareScrollView/context.tsx deleted file mode 100644 index f26e8c9872..0000000000 --- a/FabricExample/src/screens/Examples/AwareScrollView/context.tsx +++ /dev/null @@ -1,102 +0,0 @@ -declare const _IS_FABRIC: boolean; -import React, { - Component, - RefObject, - useContext, - useMemo, - useRef, -} from 'react'; -import { TextInput, findNodeHandle } from 'react-native'; -import { - useWorkletCallback, - measure as measureREA, - useSharedValue, -} from 'react-native-reanimated'; -import { useHeaderHeight } from '@react-navigation/elements'; - -type KeyboardAwareContext = { - handlersRef: { - current: Record>; - }; - handlers: { - value: Record>; - }; -}; - -const defaultValue: KeyboardAwareContext = { - handlersRef: { current: {} }, - handlers: { value: {} }, -}; - -const AwareScrollViewContext = React.createContext(defaultValue); - -export const useAwareScrollView = () => { - const ctx = useContext(AwareScrollViewContext); - const headerHeight = useHeaderHeight(); - - const onRef = (ref: TextInput | null) => { - const viewTag = findNodeHandle(ref); - if (viewTag) { - const viewTagOrShadowNode = _IS_FABRIC - ? // @ts-expect-error this API doesn't have any types - ref._internalInstanceHandle.stateNode.node - : viewTag; - ctx.handlersRef.current = { - ...ctx.handlersRef.current, - [viewTag]: () => { - 'worklet'; - - return viewTagOrShadowNode; - }, - }; - ctx.handlers.value = ctx.handlersRef.current; - } - }; - - const measure = useWorkletCallback( - (tag: number) => { - const ref = ctx.handlers.value[tag]; - - if (ref) { - const layout = measureREA(ref); - if (layout) { - return { - ...layout, - pageY: _IS_FABRIC ? layout.pageY + headerHeight : layout.pageY, - }; - } else { - return null; - } - } else { - return null; - } - }, - [headerHeight] - ); - - return { - onRef, - measure, - }; -}; - -export const AwareScrollViewProvider: React.FC> = ({ - children, -}) => { - const handlersRef = useRef({}); - const handlers = useSharedValue({}); - - const value = useMemo( - () => ({ - handlersRef, - handlers, - }), - [] - ); - - return ( - - {children} - - ); -}; diff --git a/FabricExample/src/screens/Examples/AwareScrollView/index.tsx b/FabricExample/src/screens/Examples/AwareScrollView/index.tsx index 68af6e8ecd..482dc3feac 100644 --- a/FabricExample/src/screens/Examples/AwareScrollView/index.tsx +++ b/FabricExample/src/screens/Examples/AwareScrollView/index.tsx @@ -9,11 +9,11 @@ export default function AwareScrollView() { useResizeMode(); return ( - + {new Array(10).fill(0).map((_, i) => ( ))} diff --git a/FabricExample/src/screens/Examples/AwareScrollView/styles.ts b/FabricExample/src/screens/Examples/AwareScrollView/styles.ts index 31e937d84d..b84ce5c740 100644 --- a/FabricExample/src/screens/Examples/AwareScrollView/styles.ts +++ b/FabricExample/src/screens/Examples/AwareScrollView/styles.ts @@ -2,6 +2,9 @@ import { StyleSheet } from 'react-native'; export const styles = StyleSheet.create({ container: { - flex: 1, + paddingHorizontal: 16, + }, + content: { + paddingTop: 50, }, }); diff --git a/README.md b/README.md index e22fa92f26..1b00a4f31b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Keyboard manager which works in identical way on both iOS and Android. - reanimated support 🚀 - interactive keyboard dismissing 👆📱 - re-worked prebuilt components (such as `KeyboardAvoidingView`) 📚 +- easy focused input information retrieval 📝 🔮 - works with any navigation library 🧭 - and more is coming... Stay tuned! 😊 diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/events/FocusedInputLayoutChangedEvent.kt b/android/src/main/java/com/reactnativekeyboardcontroller/events/FocusedInputLayoutChangedEvent.kt new file mode 100644 index 0000000000..7be23b5a95 --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/events/FocusedInputLayoutChangedEvent.kt @@ -0,0 +1,41 @@ +package com.reactnativekeyboardcontroller.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +data class FocusedInputLayoutChangedEventData( + val x: Double, + val y: Double, + val width: Double, + val height: Double, + val absoluteX: Double, + val absoluteY: Double, + val target: Int, +) + +class FocusedInputLayoutChangedEvent( + surfaceId: Int, + viewId: Int, + private val event: FocusedInputLayoutChangedEventData, +) : Event(surfaceId, viewId) { + override fun getEventName() = "topFocusedInputLayoutChanged" + + // All events for a given view can be coalesced + override fun getCoalescingKey(): Short = 0 + + override fun getEventData(): WritableMap? = Arguments.createMap().apply { + putInt("target", event.target) + putMap( + "layout", + Arguments.createMap().apply { + putDouble("x", event.x) + putDouble("y", event.y) + putDouble("width", event.width) + putDouble("height", event.height) + putDouble("absoluteX", event.absoluteX) + putDouble("absoluteY", event.absoluteY) + }, + ) + } +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/extensions/ThemedReactContext.kt b/android/src/main/java/com/reactnativekeyboardcontroller/extensions/ThemedReactContext.kt index 46dde68448..8c0b188827 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/extensions/ThemedReactContext.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/extensions/ThemedReactContext.kt @@ -2,6 +2,15 @@ package com.reactnativekeyboardcontroller.extensions import android.view.View import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.events.EventDispatcher val ThemedReactContext.rootView: View? get() = this.currentActivity?.window?.decorView?.rootView + +fun ThemedReactContext?.dispatchEvent(viewId: Int, event: Event<*>) { + val eventDispatcher: EventDispatcher? = + UIManagerHelper.getEventDispatcherForReactTag(this, viewId) + eventDispatcher?.dispatchEvent(event) +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/extensions/View.kt b/android/src/main/java/com/reactnativekeyboardcontroller/extensions/View.kt index 168f4263d6..dd777cba65 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/extensions/View.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/extensions/View.kt @@ -48,3 +48,10 @@ fun View.copyBoundsInWindow(rect: Rect) { ) } } + +val View.screenLocation get(): IntArray { + val point = IntArray(2) + getLocationOnScreen(point) + + return point +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputLayoutObserver.kt b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputLayoutObserver.kt new file mode 100644 index 0000000000..da957dfee1 --- /dev/null +++ b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputLayoutObserver.kt @@ -0,0 +1,93 @@ +package com.reactnativekeyboardcontroller.listeners + +import android.view.View.OnLayoutChangeListener +import android.view.ViewTreeObserver.OnGlobalFocusChangeListener +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.views.textinput.ReactEditText +import com.facebook.react.views.view.ReactViewGroup +import com.reactnativekeyboardcontroller.events.FocusedInputLayoutChangedEvent +import com.reactnativekeyboardcontroller.events.FocusedInputLayoutChangedEventData +import com.reactnativekeyboardcontroller.extensions.dispatchEvent +import com.reactnativekeyboardcontroller.extensions.dp +import com.reactnativekeyboardcontroller.extensions.screenLocation + +val noFocusedInputEvent = FocusedInputLayoutChangedEventData( + x = 0.0, + y = 0.0, + width = 0.0, + height = 0.0, + absoluteX = 0.0, + absoluteY = 0.0, + target = -1, +) + +class FocusedInputLayoutObserver(val view: ReactViewGroup, private val context: ThemedReactContext?) { + // constructor variables + private val surfaceId = UIManagerHelper.getSurfaceId(view) + + // state variables + private var lastFocusedInput: ReactEditText? = null + private var lastEventDispatched: FocusedInputLayoutChangedEventData = noFocusedInputEvent + + // listeners + private val layoutListener = + OnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> + this.syncUpLayout() + } + private val focusListener = OnGlobalFocusChangeListener { oldFocus, newFocus -> + // unfocused or focused was changed + if (newFocus == null || oldFocus != null) { + lastFocusedInput?.removeOnLayoutChangeListener(layoutListener) + lastFocusedInput = null + } + if (newFocus is ReactEditText) { + lastFocusedInput = newFocus + newFocus.addOnLayoutChangeListener(layoutListener) + this.syncUpLayout() + } + // unfocused + if (newFocus == null) { + dispatchEventToJS(noFocusedInputEvent) + } + } + + init { + view.viewTreeObserver.addOnGlobalFocusChangeListener(focusListener) + } + + fun syncUpLayout() { + val input = lastFocusedInput ?: return + + val (x, y) = input.screenLocation + val event = FocusedInputLayoutChangedEventData( + x = input.x.dp, + y = input.y.dp, + width = input.width.toFloat().dp, + height = input.height.toFloat().dp, + absoluteX = x.toFloat().dp, + absoluteY = y.toFloat().dp, + target = input.id, + ) + + dispatchEventToJS(event) + } + + fun destroy() { + view.viewTreeObserver.removeOnGlobalFocusChangeListener(focusListener) + } + + private fun dispatchEventToJS(event: FocusedInputLayoutChangedEventData) { + if (event != lastEventDispatched) { + lastEventDispatched = event + context.dispatchEvent( + view.id, + FocusedInputLayoutChangedEvent( + surfaceId, + view.id, + event = event, + ), + ) + } + } +} diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationCallback.kt b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt similarity index 76% rename from android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationCallback.kt rename to android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt index 8fb01418d3..e94eb87132 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/KeyboardAnimationCallback.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt @@ -1,9 +1,10 @@ -package com.reactnativekeyboardcontroller +package com.reactnativekeyboardcontroller.listeners import android.animation.ValueAnimator import android.os.Build import android.util.Log import android.view.View +import android.view.ViewTreeObserver.OnGlobalFocusChangeListener import androidx.core.animation.doOnEnd import androidx.core.graphics.Insets import androidx.core.view.OnApplyWindowInsetsListener @@ -15,11 +16,11 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.UIManagerHelper -import com.facebook.react.uimanager.events.Event -import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.views.textinput.ReactEditText import com.facebook.react.views.view.ReactViewGroup +import com.reactnativekeyboardcontroller.InteractiveKeyboardProvider import com.reactnativekeyboardcontroller.events.KeyboardTransitionEvent +import com.reactnativekeyboardcontroller.extensions.dispatchEvent import com.reactnativekeyboardcontroller.extensions.dp import kotlin.math.abs @@ -33,56 +34,65 @@ class KeyboardAnimationCallback( val context: ThemedReactContext?, ) : WindowInsetsAnimationCompat.Callback(dispatchMode), OnApplyWindowInsetsListener { private val surfaceId = UIManagerHelper.getSurfaceId(view) + + // state variables private var persistentKeyboardHeight = 0.0 private var isKeyboardVisible = false private var isTransitioning = false private var duration = 0 private var viewTagFocused = -1 + // listeners + private val focusListener = OnGlobalFocusChangeListener { oldFocus, newFocus -> + if (newFocus is ReactEditText) { + viewTagFocused = newFocus.id + + // keyboard is visible and focus has been changed + if (this.isKeyboardVisible && oldFocus !== null) { + // imitate iOS behavior and send two instant start/end events containing an info about new tag + // 1. onStart/onMove/onEnd can be still dispatched after, if keyboard change size (numeric -> alphabetic type) + // 2. event should be send only when keyboard is visible, since this event arrives earlier -> `tag` will be + // 100% included in onStart/onMove/onEnd lifecycles, but triggering onStart/onEnd several time + // can bring breaking changes + context.dispatchEvent( + view.id, + KeyboardTransitionEvent( + surfaceId, + view.id, + "topKeyboardMoveStart", + this.persistentKeyboardHeight, + 1.0, + 0, + viewTagFocused, + ), + ) + context.dispatchEvent( + view.id, + KeyboardTransitionEvent( + surfaceId, + view.id, + "topKeyboardMoveEnd", + this.persistentKeyboardHeight, + 1.0, + 0, + viewTagFocused, + ), + ) + this.emitEvent("KeyboardController::keyboardWillShow", getEventParams(this.persistentKeyboardHeight)) + this.emitEvent("KeyboardController::keyboardDidShow", getEventParams(this.persistentKeyboardHeight)) + } + } + } + private var layoutObserver: FocusedInputLayoutObserver? = null + init { require(persistentInsetTypes and deferredInsetTypes == 0) { "persistentInsetTypes and deferredInsetTypes can not contain any of " + " same WindowInsetsCompat.Type values" } - view.viewTreeObserver.addOnGlobalFocusChangeListener { oldFocus, newFocus -> - if (newFocus is ReactEditText) { - viewTagFocused = newFocus.id - - // keyboard is visible and focus has been changed - if (this.isKeyboardVisible && oldFocus !== null) { - // imitate iOS behavior and send two instant start/end events containing an info about new tag - // 1. onStart/onMove/onEnd can be still dispatched after, if keyboard change size (numeric -> alphabetic type) - // 2. event should be send only when keyboard is visible, since this event arrives earlier -> `tag` will be - // 100% included in onStart/onMove/onEnd lifecycles, but triggering onStart/onEnd several time - // can bring breaking changes - this.sendEventToJS( - KeyboardTransitionEvent( - surfaceId, - view.id, - "topKeyboardMoveStart", - this.persistentKeyboardHeight, - 1.0, - 0, - viewTagFocused, - ), - ) - this.sendEventToJS( - KeyboardTransitionEvent( - surfaceId, - view.id, - "topKeyboardMoveEnd", - this.persistentKeyboardHeight, - 1.0, - 0, - viewTagFocused, - ), - ) - this.emitEvent("KeyboardController::keyboardWillShow", getEventParams(this.persistentKeyboardHeight)) - this.emitEvent("KeyboardController::keyboardDidShow", getEventParams(this.persistentKeyboardHeight)) - } - } - } + layoutObserver = FocusedInputLayoutObserver(view = view, context = context) + view.viewTreeObserver.addOnGlobalFocusChangeListener(focusListener) } /** @@ -115,8 +125,10 @@ class KeyboardAnimationCallback( val keyboardHeight = getCurrentKeyboardHeight() val duration = DEFAULT_ANIMATION_TIME.toInt() + layoutObserver?.syncUpLayout() this.emitEvent("KeyboardController::keyboardWillShow", getEventParams(keyboardHeight)) - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -128,10 +140,12 @@ class KeyboardAnimationCallback( ), ) - val animation = ValueAnimator.ofFloat(this.persistentKeyboardHeight.toFloat(), keyboardHeight.toFloat()) + val animation = + ValueAnimator.ofFloat(this.persistentKeyboardHeight.toFloat(), keyboardHeight.toFloat()) animation.addUpdateListener { animator -> val toValue = animator.animatedValue as Float - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -145,7 +159,8 @@ class KeyboardAnimationCallback( } animation.doOnEnd { this.emitEvent("KeyboardController::keyboardDidShow", getEventParams(keyboardHeight)) - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -180,13 +195,15 @@ class KeyboardAnimationCallback( this.persistentKeyboardHeight = keyboardHeight } + layoutObserver?.syncUpLayout() this.emitEvent( "KeyboardController::" + if (!isKeyboardVisible) "keyboardWillHide" else "keyboardWillShow", getEventParams(keyboardHeight), ) Log.i(TAG, "HEIGHT:: $keyboardHeight TAG:: $viewTagFocused") - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -227,10 +244,24 @@ class KeyboardAnimationCallback( // do nothing, just log an exception send progress as 0 Log.w(TAG, "Caught arithmetic exception during `progress` calculation: $e") } - Log.i(TAG, "DiffY: $diffY $height $progress ${InteractiveKeyboardProvider.isInteractive} $viewTagFocused") + Log.i( + TAG, + "DiffY: $diffY $height $progress ${InteractiveKeyboardProvider.isInteractive} $viewTagFocused", + ) val event = if (InteractiveKeyboardProvider.isInteractive) "topKeyboardMoveInteractive" else "topKeyboardMove" - this.sendEventToJS(KeyboardTransitionEvent(surfaceId, view.id, event, height, progress, duration, viewTagFocused)) + context.dispatchEvent( + view.id, + KeyboardTransitionEvent( + surfaceId, + view.id, + event, + height, + progress, + duration, + viewTagFocused, + ), + ) return insets } @@ -259,7 +290,8 @@ class KeyboardAnimationCallback( "KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow", getEventParams(keyboardHeight), ) - this.sendEventToJS( + context.dispatchEvent( + view.id, KeyboardTransitionEvent( surfaceId, view.id, @@ -275,6 +307,11 @@ class KeyboardAnimationCallback( duration = 0 } + fun destroy() { + view.viewTreeObserver.removeOnGlobalFocusChangeListener(focusListener) + layoutObserver?.destroy() + } + private fun isKeyboardVisible(): Boolean { val insets = ViewCompat.getRootWindowInsets(view) @@ -290,12 +327,6 @@ class KeyboardAnimationCallback( return (keyboardHeight - navigationBar).toFloat().dp.coerceAtLeast(0.0) } - private fun sendEventToJS(event: Event<*>) { - val eventDispatcher: EventDispatcher? = - UIManagerHelper.getEventDispatcherForReactTag(context, view.id) - eventDispatcher?.dispatchEvent(event) - } - private fun emitEvent(event: String, params: WritableMap) { context?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)?.emit(event, params) diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt b/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt index 29ea43c8e0..79c2ee7f64 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/managers/KeyboardControllerViewManagerImpl.kt @@ -33,6 +33,8 @@ class KeyboardControllerViewManagerImpl(mReactContext: ReactApplicationContext) MapBuilder.of("registrationName", "onKeyboardMoveEnd"), "topKeyboardMoveInteractive", MapBuilder.of("registrationName", "onKeyboardMoveInteractive"), + "topFocusedInputLayoutChanged", + MapBuilder.of("registrationName", "onFocusedInputLayoutChanged"), ) return map diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt b/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt index dd1a0f7fdc..e5f8892e8e 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt @@ -12,10 +12,10 @@ import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsCompat import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.views.view.ReactViewGroup -import com.reactnativekeyboardcontroller.KeyboardAnimationCallback import com.reactnativekeyboardcontroller.extensions.removeSelf import com.reactnativekeyboardcontroller.extensions.requestApplyInsetsWhenAttached import com.reactnativekeyboardcontroller.extensions.rootView +import com.reactnativekeyboardcontroller.listeners.KeyboardAnimationCallback private val TAG = EdgeToEdgeReactViewGroup::class.qualifiedName @@ -30,6 +30,7 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R // internal class members private var eventView: ReactViewGroup? = null private var wasMounted = false + private var callback: KeyboardAnimationCallback? = null // region View lifecycles override fun onAttachedToWindow() { @@ -47,11 +48,7 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R override fun onDetachedFromWindow() { super.onDetachedFromWindow() - // we need to remove view asynchronously from `onDetachedFromWindow` method - // otherwise we may face NPE when app is getting opened via universal link - // see https://github.com/kirillzyusko/react-native-keyboard-controller/issues/242 - // for more details - Handler(Looper.getMainLooper()).post { this.removeKeyboardCallbacks() } + this.removeKeyboardCallbacks() } // endregion @@ -118,7 +115,7 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R val root = this.getContentView() root?.addView(eventView) - val callback = KeyboardAnimationCallback( + callback = KeyboardAnimationCallback( view = this, persistentInsetTypes = WindowInsetsCompat.Type.systemBars(), deferredInsetTypes = WindowInsetsCompat.Type.ime(), @@ -137,7 +134,18 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R } private fun removeKeyboardCallbacks() { - eventView.removeSelf() + callback?.destroy() + + // capture view into closure, because if `onDetachedFromWindow` and `onAttachedToWindow` + // dispatched synchronously after each other (open application on Fabric), then `.post` + // will destroy just newly created view (if we have a reference via `this`) + // and we'll have a memory leak or zombie-view + val view = eventView + // we need to remove view asynchronously from `onDetachedFromWindow` method + // otherwise we may face NPE when app is getting opened via universal link + // see https://github.com/kirillzyusko/react-native-keyboard-controller/issues/242 + // for more details + Handler(Looper.getMainLooper()).post { view.removeSelf() } } private fun getContentView(): FitWindowsLinearLayout? { diff --git a/docs/docs/api/hooks/input/_category_.json b/docs/docs/api/hooks/input/_category_.json new file mode 100644 index 0000000000..3e58fb0f35 --- /dev/null +++ b/docs/docs/api/hooks/input/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "📝 Input", + "position": 2 +} diff --git a/docs/docs/api/hooks/input/use-reanimated-focused-input.md b/docs/docs/api/hooks/input/use-reanimated-focused-input.md new file mode 100644 index 0000000000..12e6133f20 --- /dev/null +++ b/docs/docs/api/hooks/input/use-reanimated-focused-input.md @@ -0,0 +1,43 @@ +--- +keywords: [react-native-keyboard-controller, useReanimatedFocusedInput, react-native-reanimated, react hook, focused input, layout] +--- + +# useReanimatedFocusedInput + +Hook that returns an information about `TextInput` that currently has a focus. Returns `null` if no input has focus. + +Hook will update its value in next cases: + +- when keyboard changes its size (appears, disappears, changes size because of different input mode); +- when focus was changed from one `TextInput` to another; +- when `layout` of focused input was changed. + +:::info Events order +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 + +The `input` property from this hook is returned as `SharedValue`. The returned data has next structure: + +```ts +type KeyboardEventData = { + target: number; // tag of the focused TextInput + layout: { // layout of the focused TextInput + x: number; // `x` coordinate inside the parent component + y: number; // `y` coordinate inside the parent component + width: number; // `width` of the TextInput + height: number; // `height` of the TextInput + absoluteX: number; // `x` coordinate on the screen + absoluteY: number; // `y` coordinate on the screen + }; +}; +``` + +## Example + +```tsx +const {input} = 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/docs/docs/api/hooks/module/_category_.json b/docs/docs/api/hooks/module/_category_.json index ec194f2900..cccd2b80c9 100644 --- a/docs/docs/api/hooks/module/_category_.json +++ b/docs/docs/api/hooks/module/_category_.json @@ -1,4 +1,4 @@ { "label": "📂 Module", - "position": 2 + "position": 3 } diff --git a/example/__tests__/focused-input.spec.tsx b/example/__tests__/focused-input.spec.tsx new file mode 100644 index 0000000000..20beff6435 --- /dev/null +++ b/example/__tests__/focused-input.spec.tsx @@ -0,0 +1,62 @@ +import '@testing-library/jest-native/extend-expect'; +import React from 'react'; +import Reanimated, { useAnimatedStyle } from 'react-native-reanimated'; +import { render } from '@testing-library/react-native'; + +import { useReanimatedFocusedInput } from 'react-native-keyboard-controller'; + +function RectangleWithFocusedInputLayout() { + const { input } = useReanimatedFocusedInput(); + const style = useAnimatedStyle( + () => { + const layout = input.value?.layout; + + return { + top: layout?.y, + left: layout?.x, + height: layout?.height, + width: layout?.width, + }; + }, + [] + ); + + return ; +} + +describe('`useReanimatedFocusedInput` mocking', () => { + it('should have different styles depends on `useReanimatedFocusedInput`', () => { + const { getByTestId, update } = render(); + + expect(getByTestId('view')).toHaveStyle({ + top: 0, + left: 0, + width: 200, + height: 40, + }); + + (useReanimatedFocusedInput as jest.Mock).mockReturnValue({ + input: { + value: { + target: 2, + layout: { + x: 10, + y: 100, + width: 190, + height: 80, + absoluteX: 100, + absoluteY: 200, + }, + }, + } + }); + update(); + + expect(getByTestId('view')).toHaveStyle({ + top: 100, + left: 10, + width: 190, + height: 80, + }); + }); +}); diff --git a/example/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx b/example/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx index b7bbea795f..6019acf6ea 100644 --- a/example/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx +++ b/example/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx @@ -1,17 +1,16 @@ -import React, { Component, FC } from 'react'; +import React, { FC } from 'react'; import { ScrollViewProps, useWindowDimensions } from 'react-native'; +import { FocusedInputLayoutChangedEvent, useReanimatedFocusedInput } from 'react-native-keyboard-controller'; import Reanimated, { - MeasuredDimensions, interpolate, - measure, scrollTo, + useAnimatedReaction, useAnimatedRef, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, useWorkletCallback, } from 'react-native-reanimated'; -import { RefObjectFunction } from 'react-native-reanimated/lib/types/lib/reanimated2/hook/commonTypes'; import { useSmoothKeyboardHandler } from './useSmoothKeyboardHandler'; const BOTTOM_OFFSET = 50; @@ -61,12 +60,12 @@ const KeyboardAwareScrollView: FC = ({ const scrollViewAnimatedRef = useAnimatedRef(); const scrollPosition = useSharedValue(0); const position = useSharedValue(0); - const layout = useSharedValue(null); - const fakeViewHeight = useSharedValue(0); const keyboardHeight = useSharedValue(0); const tag = useSharedValue(-1); const initialKeyboardSize = useSharedValue(0); const scrollBeforeKeyboardMovement = useSharedValue(0); + const { input } = useReanimatedFocusedInput(); + const layout = useSharedValue(null); const { height } = useWindowDimensions(); @@ -78,20 +77,13 @@ const KeyboardAwareScrollView: FC = ({ }, [] ); - const measureByTag = useWorkletCallback((viewTag: number) => { - return measure( - (() => viewTag) as unknown as RefObjectFunction> - ); - }, []); /** * Function that will scroll a ScrollView as keyboard gets moving */ - const maybeScroll = useWorkletCallback((e: number, animated = false) => { - fakeViewHeight.value = e; - + const maybeScroll = useWorkletCallback((e: number, animated: boolean = false) => { const visibleRect = height - keyboardHeight.value; - const point = (layout.value?.pageY || 0) + (layout.value?.height || 0); + const point = (layout.value?.layout.absoluteY || 0) + (layout.value?.layout.height || 0); if (visibleRect - point <= BOTTOM_OFFSET) { const interpolatedScrollTo = interpolate( @@ -102,7 +94,11 @@ const KeyboardAwareScrollView: FC = ({ const targetScrollY = Math.max(interpolatedScrollTo, 0) + scrollPosition.value; scrollTo(scrollViewAnimatedRef, 0, targetScrollY, animated); + + return interpolatedScrollTo; } + + return 0; }, []); useSmoothKeyboardHandler( @@ -114,6 +110,8 @@ const KeyboardAwareScrollView: FC = ({ keyboardHeight.value !== e.height && e.height > 0; const keyboardWillAppear = e.height > 0 && keyboardHeight.value === 0; const keyboardWillHide = e.height === 0; + const focusWasChanged = (tag.value !== e.target && e.target !== -1) || keyboardWillChangeSize; + if (keyboardWillChangeSize) { initialKeyboardSize.value = keyboardHeight.value; } @@ -124,7 +122,7 @@ const KeyboardAwareScrollView: FC = ({ scrollPosition.value = scrollBeforeKeyboardMovement.value; } - if (keyboardWillAppear || keyboardWillChangeSize) { + if (keyboardWillAppear || keyboardWillChangeSize || focusWasChanged) { // persist scroll value scrollPosition.value = position.value; // just persist height - later will be used in interpolation @@ -132,16 +130,20 @@ const KeyboardAwareScrollView: FC = ({ } // focus was changed - if (tag.value !== e.target || keyboardWillChangeSize) { + if (focusWasChanged) { tag.value = e.target; - if (tag.value !== -1) { - // save position of focused text input when keyboard starts to move - layout.value = measureByTag(e.target); - // save current scroll position - when keyboard will hide we'll reuse - // this value to achieve smooth hide effect - scrollBeforeKeyboardMovement.value = position.value; - } + // save position of focused text input when keyboard starts to move + layout.value = input.value; + // save current scroll position - when keyboard will hide we'll reuse + // this value to achieve smooth hide effect + scrollBeforeKeyboardMovement.value = position.value; + } + + if (focusWasChanged && !keyboardWillAppear) { + // update position on scroll value, so `onEnd` handler + // will pick up correct values + position.value += maybeScroll(e.height, true); } }, onMove: (e) => { @@ -154,24 +156,24 @@ const KeyboardAwareScrollView: FC = ({ keyboardHeight.value = e.height; scrollPosition.value = position.value; - - if (e.target !== -1 && e.height !== 0) { - const prevLayout = layout.value; - // just be sure, that view is no overlapped (i.e. focus changed) - layout.value = measureByTag(e.target); - maybeScroll(e.height, true); - // do layout substitution back to assure there will be correct - // back transition when keyboard hides - layout.value = prevLayout; - } }, }, [height] ); + useAnimatedReaction(() => input.value, (current, previous) => { + if (current?.target === previous?.target && current?.layout.height !== previous?.layout.height) { + const prevLayout = layout.value; + + layout.value = input.value; + scrollPosition.value += maybeScroll(keyboardHeight.value, true); + layout.value = prevLayout; + } + }, []); + const view = useAnimatedStyle( () => ({ - height: fakeViewHeight.value, + paddingBottom: keyboardHeight.value, }), [] ); @@ -183,8 +185,9 @@ const KeyboardAwareScrollView: FC = ({ onScroll={onScroll} scrollEventThrottle={16} > - {children} - + + {children} + ); }; diff --git a/example/src/screens/Examples/AwareScrollView/TextInput.tsx b/example/src/screens/Examples/AwareScrollView/TextInput.tsx new file mode 100644 index 0000000000..f9b92bf5a1 --- /dev/null +++ b/example/src/screens/Examples/AwareScrollView/TextInput.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { StyleSheet, TextInputProps, TextInput as TextInputRN } from 'react-native'; + +const TextInput = (props: TextInputProps) => { + return ( + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + minHeight: 50, + maxHeight: 200, + marginBottom: 50, + borderColor: 'black', + borderWidth: 2, + marginRight: 160, + borderRadius: 10, + color: 'black', + paddingHorizontal: 12, + }, +}); + +export default TextInput; diff --git a/example/src/screens/Examples/AwareScrollView/index.tsx b/example/src/screens/Examples/AwareScrollView/index.tsx index d0e29ed1c9..482dc3feac 100644 --- a/example/src/screens/Examples/AwareScrollView/index.tsx +++ b/example/src/screens/Examples/AwareScrollView/index.tsx @@ -1,29 +1,20 @@ import React from 'react'; -import { TextInput } from 'react-native'; import { useResizeMode } from 'react-native-keyboard-controller'; -import { randomColor } from '../../../utils'; - import KeyboardAwareScrollView from './KeyboardAwareScrollView'; +import TextInput from './TextInput'; import { styles } from './styles'; export default function AwareScrollView() { useResizeMode(); return ( - + {new Array(10).fill(0).map((_, i) => ( ))} diff --git a/example/src/screens/Examples/AwareScrollView/styles.ts b/example/src/screens/Examples/AwareScrollView/styles.ts index 31e937d84d..b84ce5c740 100644 --- a/example/src/screens/Examples/AwareScrollView/styles.ts +++ b/example/src/screens/Examples/AwareScrollView/styles.ts @@ -2,6 +2,9 @@ import { StyleSheet } from 'react-native'; export const styles = StyleSheet.create({ container: { - flex: 1, + paddingHorizontal: 16, + }, + content: { + paddingTop: 50, }, }); diff --git a/ios/Extensions.swift b/ios/Extensions.swift index d32a82d432..fbb3be2220 100644 --- a/ios/Extensions.swift +++ b/ios/Extensions.swift @@ -52,3 +52,10 @@ public extension Optional where Wrapped == UIResponder { #endif } } + +public extension UIView { + var globalFrame: CGRect? { + let rootView = UIApplication.shared.keyWindow?.rootViewController?.view + return superview?.convert(frame, to: rootView) + } +} diff --git a/ios/KeyboardController.xcodeproj/project.pbxproj b/ios/KeyboardController.xcodeproj/project.pbxproj index f847a691a9..cb50161f24 100644 --- a/ios/KeyboardController.xcodeproj/project.pbxproj +++ b/ios/KeyboardController.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 0807071E2A34807B00C05A19 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0807071D2A34807B00C05A19 /* Extensions.swift */; }; + 084AEEC62ACF49A8001A3069 /* FocusedInputLayoutChangedEvent.m in Sources */ = {isa = PBXBuildFile; fileRef = 084AEEC52ACF49A8001A3069 /* FocusedInputLayoutChangedEvent.m */; }; + 084AEEC82ACF4AB2001A3069 /* FocusedInputLayoutObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084AEEC72ACF4AB2001A3069 /* FocusedInputLayoutObserver.swift */; }; F333F8D428996B8D0015B05F /* KeyboardControllerView.mm in Sources */ = {isa = PBXBuildFile; fileRef = F333F8D228996B8D0015B05F /* KeyboardControllerView.mm */; }; F359D34F28133C26000B6AFE /* KeyboardControllerModule.mm in Sources */ = {isa = PBXBuildFile; fileRef = F359D34E28133C26000B6AFE /* KeyboardControllerModule.mm */; }; F3626A0728B3FE760021B2D9 /* KeyboardMovementObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3626A0628B3FE760021B2D9 /* KeyboardMovementObserver.swift */; }; @@ -29,6 +31,9 @@ /* Begin PBXFileReference section */ 0807071D2A34807B00C05A19 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; + 084AEEC22ACF479A001A3069 /* FocusedInputLayoutChangedEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FocusedInputLayoutChangedEvent.h; sourceTree = ""; }; + 084AEEC52ACF49A8001A3069 /* FocusedInputLayoutChangedEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FocusedInputLayoutChangedEvent.m; sourceTree = ""; }; + 084AEEC72ACF4AB2001A3069 /* FocusedInputLayoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedInputLayoutObserver.swift; sourceTree = ""; }; 134814201AA4EA6300B7C361 /* libKeyboardController.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libKeyboardController.a; sourceTree = BUILT_PRODUCTS_DIR; }; B3E7B5891CC2AC0600A0062D /* KeyboardControllerViewManager.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = KeyboardControllerViewManager.mm; sourceTree = ""; }; F333F8D128996B1C0015B05F /* KeyboardControllerView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyboardControllerView.h; sourceTree = ""; }; @@ -58,6 +63,8 @@ children = ( F3F50667289E653B003091D6 /* KeyboardMoveEvent.h */, F3F50668289E653B003091D6 /* KeyboardMoveEvent.m */, + 084AEEC22ACF479A001A3069 /* FocusedInputLayoutChangedEvent.h */, + 084AEEC52ACF49A8001A3069 /* FocusedInputLayoutChangedEvent.m */, ); path = events; sourceTree = ""; @@ -66,6 +73,7 @@ isa = PBXGroup; children = ( F3626A0628B3FE760021B2D9 /* KeyboardMovementObserver.swift */, + 084AEEC72ACF4AB2001A3069 /* FocusedInputLayoutObserver.swift */, ); path = observers; sourceTree = ""; @@ -160,9 +168,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 084AEEC62ACF49A8001A3069 /* FocusedInputLayoutChangedEvent.m in Sources */, F3626A0728B3FE760021B2D9 /* KeyboardMovementObserver.swift in Sources */, 0807071E2A34807B00C05A19 /* Extensions.swift in Sources */, F359D34F28133C26000B6AFE /* KeyboardControllerModule.mm in Sources */, + 084AEEC82ACF4AB2001A3069 /* FocusedInputLayoutObserver.swift in Sources */, F4FF95D7245B92E800C19C63 /* KeyboardControllerViewManager.swift in Sources */, F333F8D428996B8D0015B05F /* KeyboardControllerView.mm in Sources */, F3F50669289E653B003091D6 /* KeyboardMoveEvent.m in Sources */, diff --git a/ios/events/FocusedInputLayoutChangedEvent.h b/ios/events/FocusedInputLayoutChangedEvent.h new file mode 100644 index 0000000000..c014975717 --- /dev/null +++ b/ios/events/FocusedInputLayoutChangedEvent.h @@ -0,0 +1,16 @@ +// +// FocusedInputLayoutChangedEvent.h +// KeyboardController +// +// Created by Kiryl Ziusko on 05/10/2023. +// Copyright © 2023 Facebook. All rights reserved. +// + +#import +#import + +@interface FocusedInputLayoutChangedEvent : NSObject + +- (instancetype)initWithReactTag:(NSNumber *)reactTag event:(NSObject *)event; + +@end diff --git a/ios/events/FocusedInputLayoutChangedEvent.m b/ios/events/FocusedInputLayoutChangedEvent.m new file mode 100644 index 0000000000..69b1054ac0 --- /dev/null +++ b/ios/events/FocusedInputLayoutChangedEvent.m @@ -0,0 +1,75 @@ +// +// FocusedInputLayoutChangedEvent.m +// KeyboardController +// +// Created by Kiryl Ziusko on 05/10/2023. +// Copyright © 2023 Facebook. All rights reserved. +// + +#import "FocusedInputLayoutChangedEvent.h" +#import + +@implementation FocusedInputLayoutChangedEvent { + NSNumber *_target; + NSObject *_layout; + uint16_t _coalescingKey; +} + +- (NSString *)eventName +{ + return @"onFocusedInputLayoutChanged"; +} + +@synthesize viewTag = _viewTag; + +- (instancetype)initWithReactTag:(NSNumber *)reactTag event:(NSObject *)event +{ + RCTAssertParam(reactTag); + + if ((self = [super init])) { + _viewTag = reactTag; + _target = [event valueForKey:@"target"]; + _layout = [event valueForKey:@"layout"]; + _coalescingKey = 0; + } + return self; +} + +RCT_NOT_IMPLEMENTED(-(instancetype)init) + +- (uint16_t)coalescingKey +{ + return _coalescingKey; +} + +- (NSDictionary *)body +{ + NSDictionary *body = @{ + @"target" : _target, + @"layout" : _layout, + }; + + return body; +} + +- (BOOL)canCoalesce +{ + return NO; +} + +- (FocusedInputLayoutChangedEvent *)coalesceWithEvent:(FocusedInputLayoutChangedEvent *)newEvent +{ + return newEvent; +} + ++ (NSString *)moduleDotMethod +{ + return @"RCTEventEmitter.receiveEvent"; +} + +- (NSArray *)arguments +{ + return @[ self.viewTag, RCTNormalizeInputEventName(self.eventName), [self body] ]; +} + +@end diff --git a/ios/observers/FocusedInputLayoutObserver.swift b/ios/observers/FocusedInputLayoutObserver.swift new file mode 100644 index 0000000000..fc4f3c3172 --- /dev/null +++ b/ios/observers/FocusedInputLayoutObserver.swift @@ -0,0 +1,136 @@ +// +// FocusedInputLayoutObserver.swift +// KeyboardController +// +// Created by Kiryl Ziusko on 05/10/2023. +// Copyright © 2023 Facebook. All rights reserved. +// + +import Foundation +import UIKit + +let noFocusedInputEvent: [String: Any] = [ + "target": -1, + "layout": [ + "absoluteX": 0, + "absoluteY": 0, + "width": 0, + "height": 0, + "x": 0, + "y": 0, + ], +] + +@objc(FocusedInputLayoutObserver) +public class FocusedInputLayoutObserver: NSObject { + // class members + var onEvent: (NSDictionary) -> Void + // input tracking + private var currentInput: UIView? + private var hasKVObserver = false + private var lastEventDispatched: [AnyHashable: Any] = noFocusedInputEvent + + @objc public init( + handler: @escaping (NSDictionary) -> Void + ) { + onEvent = handler + } + + @objc public func mount() { + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillShow), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + @objc public func unmount() { + // swiftlint:disable:next notification_center_detachment + NotificationCenter.default.removeObserver(self) + } + + @objc func keyboardWillShow(_: Notification) { + removeKVObserver() + currentInput = (UIResponder.current as? UIView)?.superview as UIView? + setupKVObserver() + syncUpLayout() + } + + @objc func keyboardWillHide(_: Notification) { + removeKVObserver() + dispatchEventToJS(data: noFocusedInputEvent) + } + + @objc func syncUpLayout() { + let responder = UIResponder.current + let focusedInput = (responder as? UIView)?.superview + let globalFrame = focusedInput?.globalFrame + + let data: [String: Any] = [ + "target": responder.reactViewTag, + "layout": [ + "absoluteX": globalFrame?.origin.x, + "absoluteY": globalFrame?.origin.y, + "width": focusedInput?.frame.width, + "height": focusedInput?.frame.height, + "x": focusedInput?.frame.origin.x, + "y": focusedInput?.frame.origin.y, + ], + ] + + dispatchEventToJS(data: data) + } + + private func dispatchEventToJS(data: [String: Any]) { + if NSDictionary(dictionary: data).isEqual(to: lastEventDispatched) { + return + } + + lastEventDispatched = data + onEvent(data as NSDictionary) + } + + private func setupKVObserver() { + if hasKVObserver { + return + } + + if currentInput != nil { + hasKVObserver = true + currentInput?.addObserver(self, forKeyPath: "center", options: .new, context: nil) + } + } + + private func removeKVObserver() { + if !hasKVObserver { + return + } + + hasKVObserver = false + currentInput?.removeObserver(self, forKeyPath: "center", context: nil) + } + + // swiftlint:disable:next block_based_kvo + @objc override public func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change _: [NSKeyValueChangeKey: Any]?, + context _: UnsafeMutableRawPointer? + ) { + // swiftlint:disable:next force_cast + if keyPath == "center", object as! NSObject == currentInput! { + // we need to read layout in next frame, otherwise we'll get old + // layout values + DispatchQueue.main.async { + self.syncUpLayout() + } + } + } +} diff --git a/ios/views/KeyboardControllerView.mm b/ios/views/KeyboardControllerView.mm index a7c19c122d..1b767ab2d6 100644 --- a/ios/views/KeyboardControllerView.mm +++ b/ios/views/KeyboardControllerView.mm @@ -9,6 +9,7 @@ // This guard prevent the code from being compiled in the old architecture #ifdef RCT_NEW_ARCH_ENABLED #import "KeyboardControllerView.h" +#import "FocusedInputLayoutChangedEvent.h" #import "KeyboardMoveEvent.h" #import "react_native_keyboard_controller-Swift.h" @@ -29,7 +30,8 @@ @interface KeyboardControllerView () @end @implementation KeyboardControllerView { - KeyboardMovementObserver *observer; + KeyboardMovementObserver *keyboardObserver; + FocusedInputLayoutObserver *inputObserver; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -43,7 +45,39 @@ - (instancetype)initWithFrame:(CGRect)frame static const auto defaultProps = std::make_shared(); _props = defaultProps; - observer = [[KeyboardMovementObserver alloc] + inputObserver = [[FocusedInputLayoutObserver alloc] initWithHandler:^(NSDictionary *event) { + if (self->_eventEmitter) { + int target = [event[@"target"] integerValue]; + double absoluteY = [event[@"layout"][@"absoluteY"] doubleValue]; + double absoulteX = [event[@"layout"][@"absoluteX"] doubleValue]; + double y = [event[@"layout"][@"y"] doubleValue]; + double x = [event[@"layout"][@"x"] doubleValue]; + double width = [event[@"layout"][@"width"] doubleValue]; + double height = [event[@"layout"][@"height"] doubleValue]; + + std::dynamic_pointer_cast( + self->_eventEmitter) + ->onFocusedInputLayoutChanged( + facebook::react::KeyboardControllerViewEventEmitter::OnFocusedInputLayoutChanged{ + .target = target, + .layout = facebook::react::KeyboardControllerViewEventEmitter:: + OnFocusedInputLayoutChangedLayout{ + .absoluteY = absoluteY, + .absoluteX = absoulteX, + .height = height, + .width = width, + .x = x, + .y = y}}); + // TODO: use built-in _eventEmitter once NativeAnimated module will use ModernEventemitter + RCTBridge *bridge = [RCTBridge currentBridge]; + if (bridge && [bridge valueForKey:@"_jsThread"]) { + FocusedInputLayoutChangedEvent *inputChangedEvent = + [[FocusedInputLayoutChangedEvent alloc] initWithReactTag:@(self.tag) event:event]; + [bridge.eventDispatcher sendEvent:inputChangedEvent]; + } + } + }]; + keyboardObserver = [[KeyboardMovementObserver alloc] initWithHandler:^( NSString *event, NSNumber *height, @@ -122,9 +156,11 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & if (newViewProps.enabled != oldViewProps.enabled) { if (newViewProps.enabled) { - [observer mount]; + [inputObserver mount]; + [keyboardObserver mount]; } else { - [observer unmount]; + [inputObserver unmount]; + [keyboardObserver unmount]; } } diff --git a/ios/views/KeyboardControllerViewManager.mm b/ios/views/KeyboardControllerViewManager.mm index 96a20ad7be..422ed3d001 100644 --- a/ios/views/KeyboardControllerViewManager.mm +++ b/ios/views/KeyboardControllerViewManager.mm @@ -6,9 +6,12 @@ @interface RCT_EXTERN_MODULE (KeyboardControllerViewManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(enabled, BOOL) // callbacks +/// keyboard callbacks RCT_EXPORT_VIEW_PROPERTY(onKeyboardMoveStart, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onKeyboardMove, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onKeyboardMoveEnd, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onKeyboardMoveInteractive, RCTDirectEventBlock); +/// input callbacks +RCT_EXPORT_VIEW_PROPERTY(onFocusedInputLayoutChanged, RCTDirectEventBlock); @end diff --git a/ios/views/KeyboardControllerViewManager.swift b/ios/views/KeyboardControllerViewManager.swift index 507357929f..3421a35a07 100644 --- a/ios/views/KeyboardControllerViewManager.swift +++ b/ios/views/KeyboardControllerViewManager.swift @@ -12,6 +12,7 @@ class KeyboardControllerViewManager: RCTViewManager { class KeyboardControllerView: UIView { // internal variables private var keyboardObserver: KeyboardMovementObserver? + private var inputObserver: FocusedInputLayoutObserver? private var eventDispatcher: RCTEventDispatcherProtocol private var bridge: RCTBridge // react callbacks @@ -23,8 +24,10 @@ class KeyboardControllerView: UIView { @objc var enabled: ObjCBool = true { didSet { if enabled.boolValue { + inputObserver?.mount() keyboardObserver?.mount() } else { + inputObserver?.unmount() keyboardObserver?.unmount() } } @@ -44,6 +47,7 @@ class KeyboardControllerView: UIView { override func willMove(toWindow newWindow: UIWindow?) { if newWindow == nil { // Will be removed from a window + inputObserver?.unmount() keyboardObserver?.unmount() } } @@ -51,11 +55,21 @@ class KeyboardControllerView: UIView { override func didMoveToWindow() { if window != nil { // Added to a window + inputObserver = FocusedInputLayoutObserver(handler: onInput) + inputObserver?.mount() keyboardObserver = KeyboardMovementObserver(handler: onEvent, onNotify: onNotify) keyboardObserver?.mount() } } + func onInput(event: NSObject) { + // we don't want to send event to JS before the JS thread is ready + if bridge.value(forKey: "_jsThread") == nil { + return + } + eventDispatcher.send(FocusedInputLayoutChangedEvent(reactTag: reactTag, event: event)) + } + func onEvent(event: NSString, height: NSNumber, progress: NSNumber, duration: NSNumber, target: NSNumber) { // we don't want to send event to JS before the JS thread is ready if bridge.value(forKey: "_jsThread") == nil { diff --git a/jest/index.js b/jest/index.js index edd699b2d7..4b23331044 100644 --- a/jest/index.js +++ b/jest/index.js @@ -10,6 +10,21 @@ const values = { height: { value: 0 }, }, }; +const focusedInput = { + input: { + value: { + target: 1, + layout: { + x: 0, + y: 0, + width: 200, + height: 40, + absoluteX: 0, + absoluteY: 100, + }, + }, + }, +}; const mock = { // hooks @@ -18,6 +33,7 @@ const mock = { useResizeMode: jest.fn(), useGenericKeyboardHandler: jest.fn(), useKeyboardHandler: jest.fn(), + useReanimatedFocusedInput: jest.fn().mockReturnValue(focusedInput), // modules KeyboardController: { setInputMode: jest.fn(), diff --git a/package.json b/package.json index c4ca59ed54..ceba5ee0e9 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "react-native", "keyboard", "animation", + "focused input", "ios", "android" ], diff --git a/src/animated.tsx b/src/animated.tsx index ca6190d85d..14f40a9c2e 100644 --- a/src/animated.tsx +++ b/src/animated.tsx @@ -6,10 +6,14 @@ import { KeyboardControllerView } from './bindings'; import { KeyboardContext } from './context'; import { useAnimatedValue, useSharedHandlers } from './internal'; import { applyMonkeyPatch, revertMonkeyPatch } from './monkey-patch'; -import { useAnimatedKeyboardHandler } from './reanimated'; +import { + useAnimatedKeyboardHandler, + useFocusedInputHandler, +} from './reanimated'; import type { KeyboardAnimationContext } from './context'; import type { + FocusedInputLayoutChangedEvent, KeyboardControllerProps, KeyboardHandler, NativeEvent, @@ -79,6 +83,7 @@ export const KeyboardProvider = ({ // shared values const progressSV = useSharedValue(0); const heightSV = useSharedValue(0); + const layout = useSharedValue(null); const { setHandlers, broadcast } = useSharedHandlers(); // memo const context = useMemo( @@ -86,6 +91,7 @@ export const KeyboardProvider = ({ enabled, animated: { progress: progress, height: Animated.multiply(height, -1) }, reanimated: { progress: progressSV, height: heightSV }, + layout, setHandlers, setEnabled, }), @@ -122,7 +128,7 @@ export const KeyboardProvider = ({ heightSV.value = -event.height; } }; - const handler = useAnimatedKeyboardHandler( + const keyboardHandler = useAnimatedKeyboardHandler( { onKeyboardMoveStart: (event: NativeEvent) => { 'worklet'; @@ -150,6 +156,20 @@ export const KeyboardProvider = ({ }, [] ); + const inputHandler = useFocusedInputHandler( + { + onFocusedInputLayoutChanged: (e) => { + 'worklet'; + + if (e.target !== -1) { + layout.value = e; + } else { + layout.value = null; + } + }, + }, + [] + ); // effects useEffect(() => { if (enabled) { @@ -163,10 +183,11 @@ export const KeyboardProvider = ({ ; setHandlers: (handlers: KeyboardHandlers) => void; setEnabled: React.Dispatch>; }; const NOOP = () => {}; -const DEFAULT_SHARED_VALUE: SharedValue = { - value: 0, +const withSharedValue = (value: T): SharedValue => ({ + value, addListener: NOOP, removeListener: NOOP, modify: NOOP, -}; +}); +const DEFAULT_SHARED_VALUE = withSharedValue(0); +const DEFAULT_LAYOUT = withSharedValue( + null +); const defaultContext: KeyboardAnimationContext = { enabled: true, animated: { @@ -37,6 +42,7 @@ const defaultContext: KeyboardAnimationContext = { progress: DEFAULT_SHARED_VALUE, height: DEFAULT_SHARED_VALUE, }, + layout: DEFAULT_LAYOUT, setHandlers: NOOP, setEnabled: NOOP, }; diff --git a/src/hooks.ts b/src/hooks.ts index 97743ba3e1..43859e8394 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -63,3 +63,9 @@ export function useKeyboardController() { return { setEnabled: context.setEnabled, enabled: context.enabled }; } + +export function useReanimatedFocusedInput() { + const context = useKeyboardContext(); + + return { input: context.layout }; +} diff --git a/src/reanimated.native.ts b/src/reanimated.native.ts index e46f9747fc..ea968083d7 100644 --- a/src/reanimated.native.ts +++ b/src/reanimated.native.ts @@ -1,6 +1,12 @@ import { useEvent, useHandler } from 'react-native-reanimated'; -import type { EventWithName, KeyboardHandlerHook, NativeEvent } from './types'; +import type { + EventWithName, + FocusedInputHandlerHook, + FocusedInputLayoutChangedEvent, + KeyboardHandlerHook, + NativeEvent, +} from './types'; export const useAnimatedKeyboardHandler: KeyboardHandlerHook< Record, @@ -49,3 +55,26 @@ export const useAnimatedKeyboardHandler: KeyboardHandlerHook< doDependenciesDiffer ); }; + +export const useFocusedInputHandler: FocusedInputHandlerHook< + Record, + EventWithName +> = (handlers, dependencies) => { + const { context, doDependenciesDiffer } = useHandler(handlers, dependencies); + + return useEvent( + (event) => { + 'worklet'; + const { onFocusedInputLayoutChanged } = handlers; + + if ( + onFocusedInputLayoutChanged && + event.eventName.endsWith('onFocusedInputLayoutChanged') + ) { + onFocusedInputLayoutChanged(event, context); + } + }, + ['onFocusedInputLayoutChanged'], + doDependenciesDiffer + ); +}; diff --git a/src/reanimated.ts b/src/reanimated.ts index 6380bec17b..dc7e82beb9 100644 --- a/src/reanimated.ts +++ b/src/reanimated.ts @@ -1,6 +1,17 @@ -import type { EventWithName, KeyboardHandlerHook, NativeEvent } from './types'; +import type { + EventWithName, + FocusedInputHandlerHook, + FocusedInputLayoutChangedEvent, + KeyboardHandlerHook, + NativeEvent, +} from './types'; +const NOOP = () => () => {}; export const useAnimatedKeyboardHandler: KeyboardHandlerHook< Record, EventWithName -> = () => () => {}; +> = NOOP; +export const useFocusedInputHandler: FocusedInputHandlerHook< + Record, + EventWithName +> = NOOP; diff --git a/src/specs/KeyboardControllerViewNativeComponent.ts b/src/specs/KeyboardControllerViewNativeComponent.ts index 94c35bd8a9..71a45c3274 100644 --- a/src/specs/KeyboardControllerViewNativeComponent.ts +++ b/src/specs/KeyboardControllerViewNativeComponent.ts @@ -15,6 +15,18 @@ type KeyboardMoveEvent = Readonly<{ target: Int32; }>; +type FocusedInputLayoutChangedEvent = Readonly<{ + target: Int32; + layout: { + x: Double; + y: Double; + width: Double; + height: Double; + absoluteX: Double; + absoluteY: Double; + }; +}>; + export interface NativeProps extends ViewProps { // props enabled?: boolean; @@ -25,6 +37,7 @@ export interface NativeProps extends ViewProps { onKeyboardMove?: DirectEventHandler; onKeyboardMoveEnd?: DirectEventHandler; onKeyboardMoveInteractive?: DirectEventHandler; + onFocusedInputLayoutChanged?: DirectEventHandler; } export default codegenNativeComponent( diff --git a/src/types.ts b/src/types.ts index ef49f3479b..3c58e8b509 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,17 @@ export type NativeEvent = { duration: number; target: number; }; +export type FocusedInputLayoutChangedEvent = { + target: number; + layout: { + x: number; + y: number; + width: number; + height: number; + absoluteX: number; + absoluteY: number; + }; +}; export type EventWithName = { eventName: string; } & T; @@ -30,10 +41,16 @@ export type KeyboardControllerProps = { onKeyboardMoveInteractive?: ( e: NativeSyntheticEvent> ) => void; - // fake prop used to activate reanimated bindings + onFocusedInputLayoutChanged?: ( + e: NativeSyntheticEvent> + ) => void; + // fake props used to activate reanimated bindings onKeyboardMoveReanimated?: ( e: NativeSyntheticEvent> ) => void; + onFocusedInputLayoutChangedReanimated?: ( + e: NativeSyntheticEvent> + ) => void; statusBarTranslucent?: boolean; navigationBarTranslucent?: boolean; } & ViewProps; @@ -91,6 +108,15 @@ export type KeyboardHandlerHook = ( }, dependencies?: unknown[] ) => (e: NativeSyntheticEvent) => void; +export type FocusedInputHandlerHook = ( + handlers: { + onFocusedInputLayoutChanged?: ( + e: FocusedInputLayoutChangedEvent, + context: TContext + ) => void; + }, + dependencies?: unknown[] +) => (e: NativeSyntheticEvent) => void; // package types export type Handlers = Record;