diff --git a/FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx b/FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx index 84724e1d28..c2142b03f4 100644 --- a/FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx +++ b/FabricExample/src/screens/Examples/AwareScrollView/KeyboardAwareScrollView.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react'; import { ScrollViewProps, useWindowDimensions } from 'react-native'; -import { useReanimatedFocusedInput } from 'react-native-keyboard-controller'; +import { FocusedInputLayoutChangedEvent, useReanimatedFocusedInput } from 'react-native-keyboard-controller'; import Reanimated, { interpolate, scrollTo, @@ -66,6 +66,7 @@ const KeyboardAwareScrollView: FC = ({ const initialKeyboardSize = useSharedValue(0); const scrollBeforeKeyboardMovement = useSharedValue(0); const input = useReanimatedFocusedInput(); + const layout = useSharedValue(null); const { height } = useWindowDimensions(); @@ -85,7 +86,7 @@ const KeyboardAwareScrollView: FC = ({ fakeViewHeight.value = e; const visibleRect = height - keyboardHeight.value; - const point = (input.value?.layout.absoluteY || 0) + (input.value?.layout.height || 0); + const point = (layout.value?.layout.absoluteY || 0) + (layout.value?.layout.height || 0); if (visibleRect - point <= BOTTOM_OFFSET) { const interpolatedScrollTo = interpolate( @@ -130,21 +131,23 @@ const KeyboardAwareScrollView: FC = ({ keyboardHeight.value = e.height; } - if (focusWasChanged && e.target !== -1 && !keyboardWillAppear) { - console.log("focus was changed -> scrolling"); - maybeScroll(e.height, true); - } - // focus was changed if (focusWasChanged) { tag.value = e.target; if (tag.value !== -1) { + // 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 && e.target !== -1 && !keyboardWillAppear) { + console.log("focus was changed -> scrolling"); + maybeScroll(e.height, true); + } }, onMove: (e) => { 'worklet'; @@ -164,7 +167,11 @@ const KeyboardAwareScrollView: FC = ({ useAnimatedReaction(() => input.value, (current, previous) => { if (current?.target === previous?.target && current?.layout.height !== previous?.layout.height) { console.log("TextInput grows"); + const prevLayout = layout.value; + + layout.value = input.value; scrollPosition.value += maybeScroll(keyboardHeight.value, true) || 0; + layout.value = prevLayout; } }, []); diff --git a/FabricExample/src/screens/Examples/AwareScrollView/TextInput.tsx b/FabricExample/src/screens/Examples/AwareScrollView/TextInput.tsx index 716f0d01c2..aab40aa92e 100644 --- a/FabricExample/src/screens/Examples/AwareScrollView/TextInput.tsx +++ b/FabricExample/src/screens/Examples/AwareScrollView/TextInput.tsx @@ -1,23 +1,32 @@ import React from 'react'; -import { TextInputProps, TextInput as TextInputRN } from 'react-native'; -import { randomColor } from '../../../utils'; +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, + marginTop: 50, + borderColor: 'black', + borderWidth: 2, + marginRight: 160, + borderRadius: 10, + color: 'black', + paddingHorizontal: 12, + }, +}); + export default TextInput; diff --git a/FabricExample/src/screens/Examples/AwareScrollView/index.tsx b/FabricExample/src/screens/Examples/AwareScrollView/index.tsx index cefdb2d82b..3b7b9ab988 100644 --- a/FabricExample/src/screens/Examples/AwareScrollView/index.tsx +++ b/FabricExample/src/screens/Examples/AwareScrollView/index.tsx @@ -4,17 +4,16 @@ import { useResizeMode } from 'react-native-keyboard-controller'; import KeyboardAwareScrollView from './KeyboardAwareScrollView'; import TextInput from './TextInput'; import { styles } from './styles'; -import { Keyboard, ScrollView, View } from 'react-native'; 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..0c03d2c84d 100644 --- a/FabricExample/src/screens/Examples/AwareScrollView/styles.ts +++ b/FabricExample/src/screens/Examples/AwareScrollView/styles.ts @@ -2,6 +2,6 @@ import { StyleSheet } from 'react-native'; export const styles = StyleSheet.create({ container: { - flex: 1, + paddingHorizontal: 16, }, }); diff --git a/TODO b/TODO index b6da043c75..30b641294e 100644 --- a/TODO +++ b/TODO @@ -11,49 +11,22 @@ - (x) jest -> add new mocks + new unit tests - (x) improve events precision (int -> float, pageY, width точно float или double) - (x) console.log("focus was changed -> scrolling") (even when keyboard appears) <- `!keyboardWillAppear` condition added +- (x) focus on field -> type any symbol -> close keyboard => no back transition animation <- memoize layout in local `layout` variable +- (x) android - dispatch event when keyboard changes size +- (x) be sure that after switching enabled/disabled several times only one event gets dispatched <- only single listener should be active (open app, disable/enable, focus input) +- (x) do not generate colors randomly (to cover by e2e tests later) - scrollResponderScrollNativeHandleToKeyboard - think about renaming FocusedInputLayoutChangedObserver -> FocusedInputObserver - - send `null` when there is no input in focus - const {update} = useReanimatedFocusedInput(); <- requires REA3 - crash on every file changes AwareScrollView/hot reload -- focus on field -> type any symbol -> close keyboard => no back transition animation -- android - dispatch event when keyboard changes size - enable hook under feature flag? - -``` -KeyboardTransitionEventData( - event = "topKeyboardMoveStart", - height = this.persistentKeyboardHeight, - progress = 1.0, - duration = 0, - target = viewTagFocused - ), - -data class KeyboardTransitionEventData( - val event: String, - val height: Double, - val progress: Double, - val duration: Int, - val target: Int, -) - -@Suppress("detekt:LongParameterList") -class KeyboardTransitionEvent( - surfaceId: Int, - viewId: Int, - private val data: KeyboardTransitionEventData, -) : Event(surfaceId, viewId) { - override fun getEventName() = data.event - - // All events for a given view can be coalesced? - override fun getCoalescingKey(): Short = 0 - - override fun getEventData(): WritableMap? = Arguments.createMap().apply { - putDouble("progress", data.progress) - putDouble("height", data.height) - putInt("duration", data.duration) - putInt("target", data.target) - } -} -``` +- test paper/fabric iOS/Android +- open app -> focus on 8/9 fields -> content is not moving +- 8 -> 9 transitions (9 field is covered by keyboard) +- android: keyboard resize pushes content significantly +- sometimes `paddingBottom` is getting kind of freezed (i. e. keyboard is hidden, but padding is still present) +- replicate example to paper +- `y` on Android and from measure are diffferent +- focus on 3, then on 5 -> grow text input -> first grow scroll into incorrect position diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/events/FocusedInputLayoutChangedEvent.kt b/android/src/main/java/com/reactnativekeyboardcontroller/events/FocusedInputLayoutChangedEvent.kt index a26d597d6c..7be23b5a95 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/events/FocusedInputLayoutChangedEvent.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/events/FocusedInputLayoutChangedEvent.kt @@ -4,16 +4,20 @@ 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 x: Double, - private val y: Double, - private val width: Double, - private val height: Double, - private val absoluteX: Double, - private val absoluteY: Double, - private val target: Int, + private val event: FocusedInputLayoutChangedEventData, ) : Event(surfaceId, viewId) { override fun getEventName() = "topFocusedInputLayoutChanged" @@ -21,16 +25,16 @@ class FocusedInputLayoutChangedEvent( override fun getCoalescingKey(): Short = 0 override fun getEventData(): WritableMap? = Arguments.createMap().apply { - putInt("target", target) + putInt("target", event.target) putMap( "layout", Arguments.createMap().apply { - putDouble("x", x) - putDouble("y", y) - putDouble("width", width) - putDouble("height", height) - putDouble("absoluteX", absoluteX) - putDouble("absoluteY", absoluteY) + 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/listeners/FocusedInputLayoutObserver.kt b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputLayoutObserver.kt index 5efc0839ab..26b263fa40 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputLayoutObserver.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/FocusedInputLayoutObserver.kt @@ -1,11 +1,13 @@ 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 @@ -16,44 +18,59 @@ class FocusedInputLayoutObserver(val view: ReactViewGroup, private val context: // state variables private var lastFocusedInput: ReactEditText? = null + private var lastEventDispatched: FocusedInputLayoutChangedEventData? = null // listeners private val layoutListener = OnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> - this.updateJSValue() + 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() + } + } init { - view.viewTreeObserver.addOnGlobalFocusChangeListener { oldFocus, newFocus -> - if (newFocus is ReactEditText) { - lastFocusedInput = newFocus - newFocus.addOnLayoutChangeListener(layoutListener) - this.updateJSValue() - } - if (newFocus == null) { - lastFocusedInput?.removeOnLayoutChangeListener(layoutListener) - lastFocusedInput = null - } - } + view.viewTreeObserver.addOnGlobalFocusChangeListener(focusListener) } - private fun updateJSValue() { + fun syncUpLayout() { val input = lastFocusedInput ?: return val (x, y) = input.screenLocation - context.dispatchEvent( - view.id, - FocusedInputLayoutChangedEvent( - surfaceId, - view.id, - input.x.dp, - input.y.dp, - input.width.toFloat().dp, - input.height.toFloat().dp, - x.toFloat().dp, - y.toFloat().dp, - input.id, - ), + 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, ) + + if (event != lastEventDispatched) { + lastEventDispatched = event + context.dispatchEvent( + view.id, + FocusedInputLayoutChangedEvent( + surfaceId, + view.id, + event = event, + ), + ) + println("DISPATCH $id") + } + } + + fun destroy() { + view.viewTreeObserver.removeOnGlobalFocusChangeListener(focusListener) } } diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt index 7359087a8c..e94eb87132 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt @@ -4,6 +4,7 @@ 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 @@ -33,58 +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 - 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)) - } - } - } + layoutObserver = FocusedInputLayoutObserver(view = view, context = context) + view.viewTreeObserver.addOnGlobalFocusChangeListener(focusListener) } /** @@ -117,6 +125,7 @@ class KeyboardAnimationCallback( val keyboardHeight = getCurrentKeyboardHeight() val duration = DEFAULT_ANIMATION_TIME.toInt() + layoutObserver?.syncUpLayout() this.emitEvent("KeyboardController::keyboardWillShow", getEventParams(keyboardHeight)) context.dispatchEvent( view.id, @@ -186,6 +195,7 @@ class KeyboardAnimationCallback( this.persistentKeyboardHeight = keyboardHeight } + layoutObserver?.syncUpLayout() this.emitEvent( "KeyboardController::" + if (!isKeyboardVisible) "keyboardWillHide" else "keyboardWillShow", getEventParams(keyboardHeight), @@ -297,6 +307,11 @@ class KeyboardAnimationCallback( duration = 0 } + fun destroy() { + view.viewTreeObserver.removeOnGlobalFocusChangeListener(focusListener) + layoutObserver?.destroy() + } + private fun isKeyboardVisible(): Boolean { val insets = ViewCompat.getRootWindowInsets(view) diff --git a/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt b/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt index bb7b3522a8..2fa37a140e 100644 --- a/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +++ b/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt @@ -15,7 +15,6 @@ import com.facebook.react.views.view.ReactViewGroup import com.reactnativekeyboardcontroller.extensions.removeSelf import com.reactnativekeyboardcontroller.extensions.requestApplyInsetsWhenAttached import com.reactnativekeyboardcontroller.extensions.rootView -import com.reactnativekeyboardcontroller.listeners.FocusedInputLayoutObserver import com.reactnativekeyboardcontroller.listeners.KeyboardAnimationCallback private val TAG = EdgeToEdgeReactViewGroup::class.qualifiedName @@ -31,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() { @@ -48,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 @@ -119,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(), @@ -132,16 +128,25 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R ViewCompat.setOnApplyWindowInsetsListener(it, callback) it.requestApplyInsetsWhenAttached() } - - // TODO: destroy? + KeyboardAnimationCallback destroy? - FocusedInputLayoutObserver(view = this, context = reactContext) } else { Log.w(TAG, "Can not setup keyboard animation listener, since `currentActivity` is null") } } private fun removeKeyboardCallbacks() { - eventView.removeSelf() + callback?.destroy() + + // TODO: from setActive(false) maybe need to remove synchronously? + // 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/use-reanimated-focused-input.md b/docs/docs/api/hooks/input/use-reanimated-focused-input.md index 7cf952feee..f047385801 100644 --- a/docs/docs/api/hooks/input/use-reanimated-focused-input.md +++ b/docs/docs/api/hooks/input/use-reanimated-focused-input.md @@ -9,7 +9,7 @@ Hook that returns an information about `TextInput` that currently has a focus. Hook will update its value in next cases: - when keyboard changes its size (appears, disappears, changes size because of different input mode); -- when focused was changed from one `TextInput` to another; +- when focus was changed from one `TextInput` to another; - when `layout` of focused input was changed. :::info Events order diff --git a/ios/observers/FocusedInputLayoutObserver.swift b/ios/observers/FocusedInputLayoutObserver.swift index db159c79f0..3e6e6d90a5 100644 --- a/ios/observers/FocusedInputLayoutObserver.swift +++ b/ios/observers/FocusedInputLayoutObserver.swift @@ -47,14 +47,14 @@ public class FocusedInputLayoutObserver: NSObject { removeKVObserver() currentInput = (UIResponder.current as? UIView)?.superview as UIView? setupKVObserver() - updateJSValue() + syncUpLayout() } @objc func keyboardWillHide(_: Notification) { removeKVObserver() } - @objc func updateJSValue() { + @objc func syncUpLayout() { let responder = UIResponder.current // TODO: to get a real tag/layout need to use a superview - maybe return UIResponder.current as superview? let focusedInput = (responder as? UIView)?.superview @@ -111,7 +111,7 @@ public class FocusedInputLayoutObserver: NSObject { // layout values DispatchQueue.main.async { print("KVObserver") - self.updateJSValue() + self.syncUpLayout() } } } 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" ],