diff --git a/FabricExample/patches/react-native+0.76.2.patch b/FabricExample/patches/react-native+0.76.2.patch new file mode 100644 index 000000000..da77e5629 --- /dev/null +++ b/FabricExample/patches/react-native+0.76.2.patch @@ -0,0 +1,31 @@ +diff --git a/node_modules/react-native/.DS_Store b/node_modules/react-native/.DS_Store +new file mode 100644 +index 0000000..597365c +Binary files /dev/null and b/node_modules/react-native/.DS_Store differ +diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +index e74500f..c2d4515 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm ++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +@@ -68,6 +68,8 @@ @implementation RCTTextInputComponentView { + * later comparison insensitive to them. + */ + NSDictionary *_originalTypingAttributes; ++ ++ BOOL _hasInputAccessoryView; + } + + #pragma mark - UIView overrides +@@ -590,10 +592,12 @@ - (void)setDefaultInputAccessoryView + keyboardType == UIKeyboardTypeDecimalPad || keyboardType == UIKeyboardTypeASCIICapableNumberPad) && + containsKeyType; + +- if ((_backedTextInputView.inputAccessoryView != nil) == shouldHaveInputAccessoryView) { ++ if (_hasInputAccessoryView == shouldHaveInputAccessoryView) { + return; + } + ++ _hasInputAccessoryView = shouldHaveInputAccessoryView; ++ + if (shouldHaveInputAccessoryView) { + NSString *buttonLabel = [self returnKeyTypeToString:returnKeyType]; + diff --git a/FabricExample/src/screens/Examples/InteractiveKeyboardIOS/index.tsx b/FabricExample/src/screens/Examples/InteractiveKeyboardIOS/index.tsx index 4d989e41b..37f444467 100644 --- a/FabricExample/src/screens/Examples/InteractiveKeyboardIOS/index.tsx +++ b/FabricExample/src/screens/Examples/InteractiveKeyboardIOS/index.tsx @@ -1,6 +1,9 @@ -import React, { useCallback, useRef } from "react"; -import { TextInput, View } from "react-native"; -import { useKeyboardHandler } from "react-native-keyboard-controller"; +import React, { useCallback, useRef, useState } from "react"; +import { TextInput } from "react-native"; +import { + KeyboardGestureArea, + useKeyboardHandler, +} from "react-native-keyboard-controller"; import Reanimated, { useAnimatedProps, useAnimatedScrollHandler, @@ -13,6 +16,8 @@ import { history } from "../../../components/Message/data"; import styles from "./styles"; +import type { LayoutChangeEvent } from "react-native"; + const AnimatedTextInput = Reanimated.createAnimatedComponent(TextInput); const useKeyboardAnimation = () => { @@ -86,6 +91,12 @@ const contentContainerStyle = { function InteractiveKeyboard() { const ref = useRef(null); const { height, onScroll, inset, offset } = useKeyboardAnimation(); + const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT); + const [text, setText] = useState(""); + + const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => { + setInputHeight(e.nativeEvent.layout.height); + }, []); const scrollToBottom = useCallback(() => { ref.current?.scrollToEnd({ animated: false }); @@ -94,7 +105,7 @@ function InteractiveKeyboard() { const textInputStyle = useAnimatedStyle( () => ({ position: "absolute", - height: TEXT_INPUT_HEIGHT, + minHeight: TEXT_INPUT_HEIGHT, width: "100%", backgroundColor: "#BCBCBC", transform: [{ translateY: -height.value }], @@ -113,7 +124,11 @@ function InteractiveKeyboard() { })); return ( - + ))} - - + + ); } diff --git a/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt b/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt index aeaa64d73..69adb321d 100644 --- a/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt +++ b/android/src/fabric/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt @@ -56,4 +56,12 @@ class KeyboardGestureAreaViewManager( ) { manager.setScrollKeyboardOffScreenWhenVisible(view as KeyboardGestureAreaReactViewGroup, value) } + + @ReactProp(name = "textInputNativeID") + override fun setTextInputNativeID( + view: ReactViewGroup, + value: String?, + ) { + // no-op + } } diff --git a/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt b/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt index 996485ac5..1c0c79f95 100644 --- a/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt +++ b/android/src/paper/java/com/reactnativekeyboardcontroller/KeyboardGestureAreaViewManager.kt @@ -48,4 +48,13 @@ class KeyboardGestureAreaViewManager( ) { manager.setScrollKeyboardOffScreenWhenVisible(view, value) } + + @Suppress("detekt:UnusedParameter") + @ReactProp(name = "textInputNativeID") + fun setTextInputNativeID( + view: KeyboardGestureAreaReactViewGroup, + value: String, + ) { + // no-op + } } diff --git a/docs/docs/api/keyboard-gesture-area.md b/docs/docs/api/keyboard-gesture-area.md index 6c14e7f17..10ac17b16 100644 --- a/docs/docs/api/keyboard-gesture-area.md +++ b/docs/docs/api/keyboard-gesture-area.md @@ -10,12 +10,6 @@ keywords: ] --- - - - -# KeyboardGestureArea
- - `KeyboardGestureArea` allows you to define a region on the screen, where gestures will control the keyboard position. :::info Platform availability @@ -28,27 +22,36 @@ This component is available only for Android >= 11. For iOS and Android < 11 it Extra distance to the keyboard. Default is `0`. -### `interpolator` +### `interpolator`
String with possible values `linear` and `ios`: - **ios** - interactive keyboard dismissing will work as in iOS: swipes in non-keyboard area will not affect keyboard positioning, but if your swipe touches keyboard - keyboard will follow finger position. - **linear** - gestures inside the component will linearly affect the position of the keyboard, i.e. if the user swipes down by 20 pixels, then the keyboard will also be moved down by 20 pixels, even if the gesture was not made over the keyboard area. -### `showOnSwipeUp` +### `showOnSwipeUp`
A boolean prop which allows to customize interactive keyboard behavior. If set to `true` then it allows to show keyboard (if it's already closed) by swipe up gesture. `false` by default. -### `enableSwipeToDismiss` +### `enableSwipeToDismiss`
A boolean prop which allows to customize interactive keyboard behavior. If set to `false`, then any gesture will not affect keyboard position if the keyboard is shown. `true` by default. +### `textInputNativeID`
+ +A corresponding `nativeID` value from the corresponding `TextInput`. + ## Example ```tsx - + {/* The other UI components of application in your tree */} + ``` diff --git a/example/src/screens/Examples/InteractiveKeyboardIOS/index.tsx b/example/src/screens/Examples/InteractiveKeyboardIOS/index.tsx index 4d989e41b..37f444467 100644 --- a/example/src/screens/Examples/InteractiveKeyboardIOS/index.tsx +++ b/example/src/screens/Examples/InteractiveKeyboardIOS/index.tsx @@ -1,6 +1,9 @@ -import React, { useCallback, useRef } from "react"; -import { TextInput, View } from "react-native"; -import { useKeyboardHandler } from "react-native-keyboard-controller"; +import React, { useCallback, useRef, useState } from "react"; +import { TextInput } from "react-native"; +import { + KeyboardGestureArea, + useKeyboardHandler, +} from "react-native-keyboard-controller"; import Reanimated, { useAnimatedProps, useAnimatedScrollHandler, @@ -13,6 +16,8 @@ import { history } from "../../../components/Message/data"; import styles from "./styles"; +import type { LayoutChangeEvent } from "react-native"; + const AnimatedTextInput = Reanimated.createAnimatedComponent(TextInput); const useKeyboardAnimation = () => { @@ -86,6 +91,12 @@ const contentContainerStyle = { function InteractiveKeyboard() { const ref = useRef(null); const { height, onScroll, inset, offset } = useKeyboardAnimation(); + const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT); + const [text, setText] = useState(""); + + const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => { + setInputHeight(e.nativeEvent.layout.height); + }, []); const scrollToBottom = useCallback(() => { ref.current?.scrollToEnd({ animated: false }); @@ -94,7 +105,7 @@ function InteractiveKeyboard() { const textInputStyle = useAnimatedStyle( () => ({ position: "absolute", - height: TEXT_INPUT_HEIGHT, + minHeight: TEXT_INPUT_HEIGHT, width: "100%", backgroundColor: "#BCBCBC", transform: [{ translateY: -height.value }], @@ -113,7 +124,11 @@ function InteractiveKeyboard() { })); return ( - + ))} - - + + ); } diff --git a/ios/KeyboardControllerNative/KeyboardControllerNative/Extension+UIView.swift b/ios/KeyboardControllerNative/KeyboardControllerNative/Extension+UIView.swift index ee010d42a..056035136 100644 --- a/ios/KeyboardControllerNative/KeyboardControllerNative/Extension+UIView.swift +++ b/ios/KeyboardControllerNative/KeyboardControllerNative/Extension+UIView.swift @@ -12,4 +12,8 @@ public extension UIView { var reactTag: NSNumber { return tag as NSNumber } + + var nativeID: String { + return accessibilityIdentifier ?? "" + } } diff --git a/ios/extensions/Notification.swift b/ios/extensions/Notification.swift index fdf2a78b7..9874fd229 100644 --- a/ios/extensions/Notification.swift +++ b/ios/extensions/Notification.swift @@ -15,3 +15,7 @@ extension Notification { return (duration, keyboardFrame) } } + +extension Notification.Name { + static let shouldIgnoreKeyboardEvents = Notification.Name("shouldIgnoreKeyboardEvents") +} diff --git a/ios/extensions/UIResponder.swift b/ios/extensions/UIResponder.swift index b3fdf17b7..8727b1d2b 100644 --- a/ios/extensions/UIResponder.swift +++ b/ios/extensions/UIResponder.swift @@ -44,6 +44,16 @@ public extension Optional where Wrapped == UIResponder { return (self as? UIView)?.superview?.reactTag ?? -1 #endif } + + var nativeID: String? { + guard let superview = (self as? UIView)?.superview else { return nil } + + #if KEYBOARD_CONTROLLER_NEW_ARCH_ENABLED + return (superview as NSObject).value(forKey: "nativeId") as? String + #else + return (superview as? UIView)?.nativeID + #endif + } } public extension Optional where Wrapped: UIResponder { diff --git a/ios/interactive/InvisibleInputAccessoryView.swift b/ios/interactive/InvisibleInputAccessoryView.swift new file mode 100644 index 000000000..841a4cd8d --- /dev/null +++ b/ios/interactive/InvisibleInputAccessoryView.swift @@ -0,0 +1,59 @@ +// +// InvisibleInputAccessoryView.swift +// Pods +// +// Created by Kiryl Ziusko on 01/11/2024. +// + +import Foundation +import UIKit + +public class InvisibleInputAccessoryView: UIView { + var isShown = true + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + public convenience init(height: CGFloat) { + self.init(frame: CGRect(x: 0, y: 0, width: 0, height: height)) + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setupView() + } + + override public func point(inside _: CGPoint, with _: UIEvent?) -> Bool { + // Return false to allow touch events to pass through + return false + } + + public func updateHeight(to newHeight: CGFloat) { + frame = CGRect(x: 0, y: 0, width: 0, height: newHeight) + + // Invalidate intrinsic content size to trigger a layout update + invalidateIntrinsicContentSize() + layoutIfNeeded() + } + + public func hide() { + guard isShown else { return } + isShown = false + updateHeight(to: 0.0) + superview?.layoutIfNeeded() + } + + override public var intrinsicContentSize: CGSize { + return CGSize(width: UIView.noIntrinsicMetric, height: frame.height) + } + + private func setupView() { + isUserInteractionEnabled = false + // for debug purposes + // backgroundColor = UIColor.red.withAlphaComponent(0.2) + backgroundColor = .clear + autoresizingMask = .flexibleHeight + } +} diff --git a/ios/interactive/KeyboardAreaExtender.swift b/ios/interactive/KeyboardAreaExtender.swift new file mode 100644 index 000000000..2d468c25d --- /dev/null +++ b/ios/interactive/KeyboardAreaExtender.swift @@ -0,0 +1,74 @@ +// +// KeyboardAreaExtender.swift +// Pods +// +// Created by Kiryl Ziusko on 02/11/2024. +// + +class KeyboardAreaExtender: NSObject { + private var currentInputAccessoryView: InvisibleInputAccessoryView? + + @objc public static let shared = KeyboardAreaExtender() + + override private init() { + super.init() + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardDidAppear), + name: UIResponder.keyboardDidShowNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + public var offset: CGFloat { + return currentInputAccessoryView?.frame.height ?? 0 + } + + public func hide() { + if isVisible { + NotificationCenter.default.post( + name: .shouldIgnoreKeyboardEvents, object: nil, userInfo: ["ignore": true] + ) + currentInputAccessoryView?.hide() + } + } + + public func remove() { + currentInputAccessoryView = nil + } + + public func updateHeight(to newHeight: CGFloat, for nativeID: String) { + if UIResponder.current.nativeID == nativeID { + currentInputAccessoryView?.updateHeight(to: newHeight) + } + } + + private var isVisible: Bool { + return currentInputAccessoryView?.isShown ?? false + } + + @objc private func keyboardDidAppear(_: Notification) { + let responder = UIResponder.current + if let activeTextInput = responder as? TextInput, + let offset = KeyboardOffsetProvider.shared.getOffset( + forTextInputNativeID: responder.nativeID), + responder?.inputAccessoryView == nil + { + currentInputAccessoryView = InvisibleInputAccessoryView(height: CGFloat(truncating: offset)) + + // we need to send this event before we actually attach the IAV + // since on some OS versions (iOS 15 for example), `reloadInputViews` + // will trigger `keyboardDidAppear` listener immediately + NotificationCenter.default.post( + name: .shouldIgnoreKeyboardEvents, object: nil, userInfo: ["ignore": true] + ) + + activeTextInput.inputAccessoryView = currentInputAccessoryView + activeTextInput.reloadInputViews() + } + } +} diff --git a/ios/interactive/KeyboardOffsetProvider.swift b/ios/interactive/KeyboardOffsetProvider.swift new file mode 100644 index 000000000..a56da2381 --- /dev/null +++ b/ios/interactive/KeyboardOffsetProvider.swift @@ -0,0 +1,39 @@ +// +// KeyboardOffsetProvider.swift +// Pods +// +// Created by Kiryl Ziusko on 01/11/2024. +// + +import Foundation + +@objc(KeyboardOffsetProvider) +public class KeyboardOffsetProvider: NSObject { + @objc public static let shared = KeyboardOffsetProvider() + + override private init() {} + + private var offsetMap: [String: (offset: NSNumber, tag: NSNumber)] = [:] + + @objc public func setOffset(forTextInputNativeID nativeID: String, offset: NSNumber, withTag tag: NSNumber) { + KeyboardAreaExtender.shared.updateHeight(to: CGFloat(offset), for: nativeID) + offsetMap[nativeID] = (offset: offset, tag: tag) + } + + @objc public func getOffset(forTextInputNativeID nativeID: String?) -> NSNumber? { + guard let unwrappedNativeID = nativeID, let pair = offsetMap[unwrappedNativeID] else { return nil } + return pair.offset + } + + @objc public func hasOffset(forTextInputNativeID nativeID: String?) -> Bool { + guard let unwrappedNativeID = nativeID else { return false } + return offsetMap[unwrappedNativeID] != nil + } + + @objc public func removeOffset(forTextInputNativeID nativeID: String, withTag tag: NSNumber) { + // Ensure the tag matches before removing the entry + if let currentPair = offsetMap[nativeID], currentPair.tag == tag { + offsetMap.removeValue(forKey: nativeID) + } + } +} diff --git a/ios/observers/KeyboardEventsIgnorer.swift b/ios/observers/KeyboardEventsIgnorer.swift new file mode 100644 index 000000000..46781fda5 --- /dev/null +++ b/ios/observers/KeyboardEventsIgnorer.swift @@ -0,0 +1,39 @@ +// +// KeyboardEventsIgnorer.swift +// Pods +// +// Created by Kiryl Ziusko on 24/11/2024. +// + +import Foundation + +@objc(KeyboardEventsIgnorer) +public class KeyboardEventsIgnorer: NSObject { + @objc public static let shared = KeyboardEventsIgnorer() + + var shouldIgnoreKeyboardEvents = false + + public var shouldIgnore: Bool { + return shouldIgnoreKeyboardEvents + } + + override init() { + super.init() + NotificationCenter.default.addObserver( + self, + selector: #selector(handleIgnoreKeyboardEventsNotification), + name: .shouldIgnoreKeyboardEvents, + object: nil + ) + } + + @objc private func handleIgnoreKeyboardEventsNotification(_ notification: Notification) { + if let userInfo = notification.userInfo, let value = userInfo["ignore"] as? Bool { + shouldIgnoreKeyboardEvents = value + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} diff --git a/ios/observers/KeyboardMovementObserver.swift b/ios/observers/KeyboardMovementObserver.swift index d281499d0..4134195ef 100644 --- a/ios/observers/KeyboardMovementObserver.swift +++ b/ios/observers/KeyboardMovementObserver.swift @@ -36,7 +36,12 @@ public class KeyboardMovementObserver: NSObject { private var hasKVObserver = false private var isMounted = false // state variables - private var keyboardHeight: CGFloat = 0.0 + private var _keyboardHeight: CGFloat = 0.0 + private var keyboardHeight: CGFloat { + get { _keyboardHeight - KeyboardAreaExtender.shared.offset } + set { _keyboardHeight = newValue } + } + private var duration = 0 private var tag: NSNumber = -1 private var animation: KeyboardAnimation? @@ -121,7 +126,7 @@ public class KeyboardMovementObserver: NSObject { } // if keyboard height is not equal to its bounds - we can ignore // values, since they'll be invalid and will cause UI jumps - if keyboardView?.bounds.size.height != keyboardHeight { + if floor(keyboardView?.bounds.size.height ?? 0) != floor(_keyboardHeight) { return } @@ -131,11 +136,12 @@ public class KeyboardMovementObserver: NSObject { let keyboardFrameY = changeValue.cgPointValue.y let keyboardWindowH = keyboardView?.window?.bounds.size.height ?? 0 let keyboardPosition = keyboardWindowH - keyboardFrameY + let position = CGFloat.interpolate( - inputRange: [keyboardHeight / 2, -keyboardHeight / 2], - outputRange: [keyboardHeight, 0], + inputRange: [_keyboardHeight / 2, -_keyboardHeight / 2], + outputRange: [_keyboardHeight, 0], currentValue: keyboardPosition - ) + ) - KeyboardAreaExtender.shared.offset if position == 0 { // it will be triggered before `keyboardWillDisappear` and @@ -163,6 +169,8 @@ public class KeyboardMovementObserver: NSObject { } @objc func keyboardWillAppear(_ notification: Notification) { + guard !KeyboardEventsIgnorer.shared.shouldIgnore else { return } + let (duration, frame) = notification.keyboardMetaData() if let keyboardFrame = frame { tag = UIResponder.current.reactViewTag @@ -172,16 +180,16 @@ public class KeyboardMovementObserver: NSObject { didShowDeadline = Date.currentTimeStamp + Int64(duration) onRequestAnimation() - onEvent("onKeyboardMoveStart", Float(keyboardHeight) as NSNumber, 1, duration as NSNumber, tag) - onNotify("KeyboardController::keyboardWillShow", buildEventParams(keyboardHeight, duration, tag)) + onEvent("onKeyboardMoveStart", Float(self.keyboardHeight) as NSNumber, 1, duration as NSNumber, tag) + onNotify("KeyboardController::keyboardWillShow", buildEventParams(self.keyboardHeight, duration, tag)) setupKeyboardWatcher() - initializeAnimation(fromValue: prevKeyboardPosition, toValue: keyboardHeight) + initializeAnimation(fromValue: prevKeyboardPosition, toValue: self.keyboardHeight) } } @objc func keyboardWillDisappear(_ notification: Notification) { - let (duration, _) = notification.keyboardMetaData() + let (duration, keyboardFrame) = notification.keyboardMetaData() tag = UIResponder.current.reactViewTag self.duration = duration @@ -202,9 +210,15 @@ public class KeyboardMovementObserver: NSObject { let keyboardHeight = keyboardFrame.cgRectValue.size.height tag = UIResponder.current.reactViewTag self.keyboardHeight = keyboardHeight + + guard !KeyboardEventsIgnorer.shared.shouldIgnore else { + KeyboardEventsIgnorer.shared.shouldIgnoreKeyboardEvents = false + return + } + // if the event is caught in between it's highly likely that it could be a "resize" event // so we just read actual keyboard frame value in this case - let height = timestamp >= didShowDeadline ? keyboardHeight : position + let height = timestamp >= didShowDeadline ? self.keyboardHeight : position - KeyboardAreaExtender.shared.offset // always limit progress to the maximum possible value let progress = min(height / self.keyboardHeight, 1.0) @@ -219,7 +233,7 @@ public class KeyboardMovementObserver: NSObject { } @objc func keyboardDidDisappear(_ notification: Notification) { - let (duration, _) = notification.keyboardMetaData() + let (duration, keyboardFrame) = notification.keyboardMetaData() tag = UIResponder.current.reactViewTag onCancelAnimation() @@ -267,7 +281,7 @@ public class KeyboardMovementObserver: NSObject { } let (visibleKeyboardHeight, keyboardFrameY) = keyboardView.frameTransitionInWindow - var keyboardPosition = visibleKeyboardHeight + var keyboardPosition = visibleKeyboardHeight - KeyboardAreaExtender.shared.offset if keyboardPosition == prevKeyboardPosition || keyboardFrameY == 0 { return diff --git a/ios/protocols/TextInput.swift b/ios/protocols/TextInput.swift index 57ff04a36..49b50cb9f 100644 --- a/ios/protocols/TextInput.swift +++ b/ios/protocols/TextInput.swift @@ -11,9 +11,11 @@ import UIKit public protocol TextInput: UIView { // default common methods/properties + var inputAccessoryView: UIView? { get set } var inputView: UIView? { get set } var keyboardType: UIKeyboardType { get } var keyboardAppearance: UIKeyboardAppearance { get } + // custom methods/properties func focus() } diff --git a/ios/swizzling/UIResponderSwizzle.swift b/ios/swizzling/UIResponderSwizzle.swift new file mode 100644 index 000000000..b0484910d --- /dev/null +++ b/ios/swizzling/UIResponderSwizzle.swift @@ -0,0 +1,60 @@ +// +// UIResponderSwizzle.swift +// Pods +// +// Created by Kiryl Ziusko on 01/11/2024. +// + +import Foundation +import UIKit + +private var originalResignFirstResponder: IMP? + +@objc +extension UIResponder { + public static func swizzleResignFirstResponder() { + let originalSelector = #selector(resignFirstResponder) + + guard let originalMethod = class_getInstanceMethod(UIResponder.self, originalSelector) else { + return + } + + originalResignFirstResponder = method_getImplementation(originalMethod) + + let swizzledImplementation: @convention(block) (UIResponder) -> Bool = { (self) in + // Check the type of responder + if let textField = self as? TextInput { + // check inputAccessoryView and call original method immediately if not InvisibleInputAccessoryView + if !(textField.inputAccessoryView is InvisibleInputAccessoryView) { + return self.callOriginalResignFirstResponder(originalSelector) + } + } else { + // If casting to TextInput fails + return self.callOriginalResignFirstResponder(originalSelector) + } + + KeyboardAreaExtender.shared.hide() + + // Postpone execution of the original resignFirstResponder + DispatchQueue.main.asyncAfter(deadline: .now() + UIUtils.nextFrame) { + (self as? TextInput)?.inputAccessoryView = nil + KeyboardAreaExtender.shared.remove() + _ = self.callOriginalResignFirstResponder(originalSelector) + } + + // We need to return a value immediately, even though the actual action is delayed + return false + } + + let implementation = imp_implementationWithBlock(swizzledImplementation) + method_setImplementation(originalMethod, implementation) + } + + private func callOriginalResignFirstResponder(_ selector: Selector) -> Bool { + guard let originalResignFirstResponder = originalResignFirstResponder else { return false } + typealias Function = @convention(c) (AnyObject, Selector) -> Bool + let castOriginalResignFirstResponder = unsafeBitCast(originalResignFirstResponder, to: Function.self) + let result = castOriginalResignFirstResponder(self, selector) + return result + } +} diff --git a/ios/views/KeyboardGestureAreaManager.h b/ios/views/KeyboardGestureAreaManager.h new file mode 100644 index 000000000..29f916e84 --- /dev/null +++ b/ios/views/KeyboardGestureAreaManager.h @@ -0,0 +1,31 @@ +// +// KeyboardGestureAreaManager.h +// Pods +// +// Created by Kiryl Ziusko on 01/11/2024. +// + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#else +#import +#endif +#import +#import + +@interface KeyboardGestureAreaManager : RCTViewManager +@end + +@interface KeyboardGestureArea : +#ifdef RCT_NEW_ARCH_ENABLED + RCTViewComponentView +#else + UIView + +- (instancetype)initWithBridge:(RCTBridge *)bridge; + +#endif + +@property (nonatomic, strong) NSNumber *offset; +@property (nonatomic, strong) NSString *textInputNativeID; +@end diff --git a/ios/views/KeyboardGestureAreaManager.mm b/ios/views/KeyboardGestureAreaManager.mm new file mode 100644 index 000000000..74309f9a3 --- /dev/null +++ b/ios/views/KeyboardGestureAreaManager.mm @@ -0,0 +1,174 @@ +// +// KeyboardGestureAreaManager.mm +// Pods +// +// Created by Kiryl Ziusko on 01/11/2024. +// + +#import "KeyboardGestureAreaManager.h" + +#if __has_include("react_native_keyboard_controller-Swift.h") +#import "react_native_keyboard_controller-Swift.h" +#else +#import +#endif + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import +#import +#import + +#import "RCTFabricComponentsPlugins.h" +#endif + +#import + +#ifdef RCT_NEW_ARCH_ENABLED +using namespace facebook::react; +#endif + +// MARK: Manager +@implementation KeyboardGestureAreaManager + +RCT_EXPORT_MODULE(KeyboardGestureAreaManager) + +// Expose props to React Native +RCT_EXPORT_VIEW_PROPERTY(textInputNativeID, NSString *) +RCT_EXPORT_VIEW_PROPERTY(offset, NSNumber *) + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +#ifndef RCT_NEW_ARCH_ENABLED +- (UIView *)view +{ + return [[KeyboardGestureArea alloc] initWithBridge:self.bridge]; +} +#endif + +@end + +// MARK: View +#ifdef RCT_NEW_ARCH_ENABLED +@interface KeyboardGestureArea () +#else +@interface KeyboardGestureArea () +#endif +@end + +@implementation KeyboardGestureArea { +} + +#ifdef RCT_NEW_ARCH_ENABLED ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} +#endif + +// Needed because of this: https://github.com/facebook/react-native/pull/37274 ++ (void)load +{ + [super load]; + + [UIResponder swizzleResignFirstResponder]; +} + +// MARK: Constructor +#ifdef RCT_NEW_ARCH_ENABLED +- (instancetype)init +{ + if (self = [super init]) { + } + return self; +} +#else +- (instancetype)initWithBridge:(RCTBridge *)bridge +{ + self = [super initWithFrame:CGRectZero]; + if (self) { + } + + return self; +} +#endif + +// MARK: lifecycle methods +- (void)didMoveToSuperview +{ + if (self.superview == nil) { + // unmounted + [[KeyboardOffsetProvider shared] removeOffsetForTextInputNativeID:_textInputNativeID + withTag:self.reactTag]; + } +} + +// MARK: props updater +#ifdef RCT_NEW_ARCH_ENABLED +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &newViewProps = *std::static_pointer_cast(props); + const KeyboardGestureAreaProps *oldViewPropsPtr = + oldProps ? std::static_pointer_cast(oldProps).get() : nullptr; + NSString *newTextInputNativeID = !newViewProps.textInputNativeID.empty() + ? [NSString stringWithUTF8String:newViewProps.textInputNativeID.c_str()] + : nil; + NSString *oldTextInputNativeID = (oldViewPropsPtr && !oldViewPropsPtr->textInputNativeID.empty()) + ? [NSString stringWithUTF8String:oldViewPropsPtr->textInputNativeID.c_str()] + : nil; + NSNumber *tag = [NSNumber numberWithInteger:self.tag]; + NSNumber *newOffset = @(newViewProps.offset); + + if (newTextInputNativeID != oldTextInputNativeID) { + if (oldTextInputNativeID) { + [[KeyboardOffsetProvider shared] removeOffsetForTextInputNativeID:oldTextInputNativeID + withTag:tag]; + } + if (newTextInputNativeID) { + [[KeyboardOffsetProvider shared] setOffsetForTextInputNativeID:newTextInputNativeID + offset:newOffset + withTag:tag]; + } + } else if (!oldViewPropsPtr || newViewProps.offset != oldViewPropsPtr->offset) { + if (newTextInputNativeID) { + [[KeyboardOffsetProvider shared] removeOffsetForTextInputNativeID:newTextInputNativeID + withTag:tag]; + [[KeyboardOffsetProvider shared] setOffsetForTextInputNativeID:newTextInputNativeID + offset:newOffset + withTag:tag]; + } + } + + [super updateProps:props oldProps:oldProps]; +} +#else +- (void)setOffset:(NSNumber *)offset +{ + [[KeyboardOffsetProvider shared] setOffsetForTextInputNativeID:_textInputNativeID + offset:offset + withTag:self.reactTag]; + _offset = offset; +} + +- (void)setTextInputNativeID:(NSString *)textInputNativeID +{ + [[KeyboardOffsetProvider shared] removeOffsetForTextInputNativeID:_textInputNativeID + withTag:self.reactTag]; + [[KeyboardOffsetProvider shared] setOffsetForTextInputNativeID:textInputNativeID + offset:_offset + withTag:self.reactTag]; + _textInputNativeID = textInputNativeID; +} +#endif + +#ifdef RCT_NEW_ARCH_ENABLED +Class KeyboardGestureAreaCls(void) +{ + return KeyboardGestureArea.class; +} +#endif + +@end diff --git a/src/bindings.native.ts b/src/bindings.native.ts index c1ea9dcd7..d1621d183 100644 --- a/src/bindings.native.ts +++ b/src/bindings.native.ts @@ -55,7 +55,7 @@ export const WindowDimensionsEvents: WindowDimensionsEventsModule = { export const KeyboardControllerView: React.FC = require("./specs/KeyboardControllerViewNativeComponent").default; export const KeyboardGestureArea: React.FC = - Platform.OS === "android" && Platform.Version >= 30 + (Platform.OS === "android" && Platform.Version >= 30) || Platform.OS === "ios" ? require("./specs/KeyboardGestureAreaNativeComponent").default : ({ children }: KeyboardGestureAreaProps) => children; export const RCTOverKeyboardView: React.FC = diff --git a/src/specs/KeyboardGestureAreaNativeComponent.ts b/src/specs/KeyboardGestureAreaNativeComponent.ts index d28e492ec..58fa63863 100644 --- a/src/specs/KeyboardGestureAreaNativeComponent.ts +++ b/src/specs/KeyboardGestureAreaNativeComponent.ts @@ -12,8 +12,9 @@ export interface NativeProps extends ViewProps { showOnSwipeUp?: boolean; enableSwipeToDismiss?: boolean; offset?: Double; + textInputNativeID?: string; } -export default codegenNativeComponent("KeyboardGestureArea", { - excludedPlatforms: ["iOS"], -}) as HostComponent; +export default codegenNativeComponent( + "KeyboardGestureArea", +) as HostComponent; diff --git a/src/types.ts b/src/types.ts index bf0f1474b..e9f6d9604 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,7 +93,7 @@ export type KeyboardControllerProps = { } & ViewProps; export type KeyboardGestureAreaProps = { - interpolator: "ios" | "linear"; + interpolator?: "ios" | "linear"; /** * Whether to allow to show a keyboard from dismissed state by swipe up. * Default to `false`. @@ -109,6 +109,10 @@ export type KeyboardGestureAreaProps = { * Extra distance to the keyboard. */ offset?: number; + /** + * A corresponding `nativeID` value from the corresponding `TextInput`. + */ + textInputNativeID?: string; } & ViewProps; export type OverKeyboardViewProps = PropsWithChildren<{ visible: boolean;