From 0154372b93eb1b02f0c62f2a75c95f4fc6a9f3e8 Mon Sep 17 00:00:00 2001 From: Mykhailo Kravchenko Date: Mon, 6 Jan 2025 09:54:29 -0800 Subject: [PATCH] feat: Manage keyboard shortcuts visibility of TextInput (#47671) Summary: **iOS** does offer a native property for **UITextField** called `inputAssistantItem`. According to the [documentation](https://developer.apple.com/documentation/uikit/uitextinputassistantitem), we can hide the **"shortcuts"** by setting the `leadingBarButtonGroups` and `trailingBarButtonGroups` properties to empty arrays. I propose adding a new property for **TextInput** in **React Native**, which would set these native properties to empty arrays. This new property could be called `disableInputAssistant` or `disableKeyboardShortcuts` and would be a `boolean`. Developers can manage this behavior (the redo & undo buttons and suggestions pop-up hiding) after applying these native props. https://github.com/react-native-community/discussions-and-proposals/discussions/830 ## Changelog: [IOS] [ADDED] - [TextInput] Integrate a new property - `disableKeyboardShortcuts`. It can disable the keyboard shortcuts on iPads. Pull Request resolved: https://github.com/facebook/react-native/pull/47671 Test Plan: Manual 1. Open TextInput examples. 2. Scroll down and reach the "Keyboard shortcuts" section. 3. Test each case. Note: **TextInput** behaves the same as now when the new prop is not passed or is `false`. https://github.com/user-attachments/assets/5e814516-9e6c-4495-9d46-8175425c4456 Reviewed By: javache Differential Revision: D67451609 Pulled By: cipolleschi fbshipit-source-id: 59ba3a5cc1644ed176420f82dc98232d88341c6e --- .../TextInput/RCTTextInputViewConfig.js | 1 + .../Components/TextInput/TextInput.d.ts | 5 ++ .../Components/TextInput/TextInput.flow.js | 6 ++ .../Components/TextInput/TextInput.js | 6 ++ .../Text/TextInput/Multiline/RCTUITextView.h | 2 + .../Text/TextInput/Multiline/RCTUITextView.mm | 24 ++++++ .../RCTBackedTextInputViewProtocol.h | 2 + .../Text/TextInput/RCTBaseTextInputView.mm | 23 ++++++ .../TextInput/RCTBaseTextInputViewManager.mm | 2 + .../TextInput/Singleline/RCTUITextField.h | 1 + .../TextInput/Singleline/RCTUITextField.mm | 24 ++++++ .../__snapshots__/public-api-test.js.snap | 2 + .../TextInput/RCTTextInputComponentView.mm | 5 ++ .../TextInput/RCTTextInputUtils.mm | 1 + .../textinput/BaseTextInputProps.cpp | 7 ++ .../components/textinput/BaseTextInputProps.h | 2 + .../TextInput/TextInputExample.ios.js | 73 +++++++++++++++++++ 17 files changed, 186 insertions(+) diff --git a/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js index eb314f27876940..2e528a415b49d2 100644 --- a/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js +++ b/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js @@ -162,6 +162,7 @@ const RCTTextInputViewConfig = { onChangeSync: true, onKeyPressSync: true, }), + disableKeyboardShortcuts: true, }, }; diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts b/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts index e9b4e760980c22..897824e4401b99 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts @@ -136,6 +136,11 @@ export interface DocumentSelectionState extends EventEmitter { * @see https://reactnative.dev/docs/textinput#props */ export interface TextInputIOSProps { + /** + * If true, the keyboard shortcuts (undo/redo and copy buttons) are disabled. The default value is false. + */ + disableKeyboardShortcuts?: boolean | undefined; + /** * enum('never', 'while-editing', 'unless-editing', 'always') * When the clear button should appear on the right side of the text view diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index b61fe39916a011..faee19fecb7679 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -216,6 +216,12 @@ export type enterKeyHintType = type PasswordRules = string; type IOSProps = $ReadOnly<{| + /** + * If true, the keyboard shortcuts (undo/redo and copy buttons) are disabled. The default value is false. + * @platform ios + */ + disableKeyboardShortcuts?: ?boolean, + /** * When the clear button should appear on the right side of the text view. * This property is supported only for single-line TextInput component. diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index dcf11237599d2f..48a3c99609a870 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -260,6 +260,12 @@ export type enterKeyHintType = type PasswordRules = string; type IOSProps = $ReadOnly<{| + /** + * If true, the keyboard shortcuts (undo/redo and copy buttons) are disabled. The default value is false. + * @platform ios + */ + disableKeyboardShortcuts?: ?boolean, + /** * When the clear button should appear on the right side of the text view. * This property is supported only for single-line TextInput component. diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h index e176ff398505dc..be946f87bbe311 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.h @@ -39,6 +39,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong, nullable) NSString *inputAccessoryViewID; @property (nonatomic, strong, nullable) NSString *inputAccessoryViewButtonLabel; +@property (nonatomic, assign) BOOL disableKeyboardShortcuts; + @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index d5e2e220b1e290..f3d4a3f0136c30 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -18,6 +18,8 @@ @implementation RCTUITextView { UITextView *_detachedTextView; RCTBackedTextViewDelegateAdapter *_textInputDelegateAdapter; NSDictionary *_defaultTextAttributes; + NSArray *_initialValueLeadingBarButtonGroups; + NSArray *_initialValueTrailingBarButtonGroups; } static UIFont *defaultPlaceholderFont(void) @@ -52,6 +54,8 @@ - (instancetype)initWithFrame:(CGRect)frame self.textContainer.lineFragmentPadding = 0; self.scrollsToTop = NO; self.scrollEnabled = YES; + _initialValueLeadingBarButtonGroups = nil; + _initialValueTrailingBarButtonGroups = nil; } return self; @@ -132,6 +136,26 @@ - (void)textDidChange [self _invalidatePlaceholderVisibility]; } +- (void)setDisableKeyboardShortcuts:(BOOL)disableKeyboardShortcuts +{ + // Initialize the initial values only once + if (_initialValueLeadingBarButtonGroups == nil) { + // Capture initial values of leading and trailing button groups + _initialValueLeadingBarButtonGroups = self.inputAssistantItem.leadingBarButtonGroups; + _initialValueTrailingBarButtonGroups = self.inputAssistantItem.trailingBarButtonGroups; + } + + if (disableKeyboardShortcuts) { + self.inputAssistantItem.leadingBarButtonGroups = @[]; + self.inputAssistantItem.trailingBarButtonGroups = @[]; + } else { + // Restore the initial values + self.inputAssistantItem.leadingBarButtonGroups = _initialValueLeadingBarButtonGroups; + self.inputAssistantItem.trailingBarButtonGroups = _initialValueTrailingBarButtonGroups; + } + _disableKeyboardShortcuts = disableKeyboardShortcuts; +} + #pragma mark - Overrides - (void)setFont:(UIFont *)font diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h index e3df41a87f3a4d..1f1af7ee7f02d5 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h +++ b/packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h @@ -52,6 +52,8 @@ NS_ASSUME_NONNULL_BEGIN // Use `attributedText.string` instead. @property (nonatomic, copy, nullable) NSString *text NS_UNAVAILABLE; +@property (nonatomic, assign) BOOL disableKeyboardShortcuts; + @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm index 6deea7391e4454..f35faca4051fab 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm +++ b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm @@ -30,6 +30,8 @@ @implementation RCTBaseTextInputView { BOOL _hasInputAccessoryView; NSString *_Nullable _predictedText; BOOL _didMoveToWindow; + NSArray *_initialValueLeadingBarButtonGroups; + NSArray *_initialValueTrailingBarButtonGroups; } - (void)reactUpdateResponderOffsetForScrollView:(RCTScrollView *)scrollView @@ -65,6 +67,8 @@ - (instancetype)initWithBridge:(RCTBridge *)bridge _bridge = bridge; _eventDispatcher = bridge.eventDispatcher; [self initializeReturnKeyType]; + _initialValueLeadingBarButtonGroups = nil; + _initialValueTrailingBarButtonGroups = nil; } return self; @@ -394,6 +398,25 @@ - (void)setInputAccessoryViewButtonLabel:(NSString *)inputAccessoryViewButtonLab self.backedTextInputView.inputAccessoryViewButtonLabel = inputAccessoryViewButtonLabel; } +- (void)setDisableKeyboardShortcuts:(BOOL)disableKeyboardShortcuts +{ + // Initialize the initial values only once + if (_initialValueLeadingBarButtonGroups == nil) { + // Capture initial values of leading and trailing button groups + _initialValueLeadingBarButtonGroups = self.backedTextInputView.inputAssistantItem.leadingBarButtonGroups; + _initialValueTrailingBarButtonGroups = self.backedTextInputView.inputAssistantItem.trailingBarButtonGroups; + } + + if (disableKeyboardShortcuts) { + self.backedTextInputView.inputAssistantItem.leadingBarButtonGroups = @[]; + self.backedTextInputView.inputAssistantItem.trailingBarButtonGroups = @[]; + } else { + // Restore the initial values + self.backedTextInputView.inputAssistantItem.leadingBarButtonGroups = _initialValueLeadingBarButtonGroups; + self.backedTextInputView.inputAssistantItem.trailingBarButtonGroups = _initialValueTrailingBarButtonGroups; + } +} + #pragma mark - RCTBackedTextInputDelegate - (BOOL)textInputShouldBeginEditing diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm index 9a010a3d182a5e..dd3969933904eb 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm +++ b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm @@ -69,6 +69,8 @@ @implementation RCTBaseTextInputViewManager { RCT_EXPORT_VIEW_PROPERTY(mostRecentEventCount, NSInteger) +RCT_EXPORT_VIEW_PROPERTY(disableKeyboardShortcuts, BOOL) + RCT_EXPORT_SHADOW_PROPERTY(text, NSString) RCT_EXPORT_SHADOW_PROPERTY(placeholder, NSString) RCT_EXPORT_SHADOW_PROPERTY(onContentSizeChange, RCTDirectEventBlock) diff --git a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.h b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.h index 13c0b229eb5990..fe6d957aa2c7ba 100644 --- a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.h +++ b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.h @@ -34,6 +34,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign, readonly) CGFloat zoomScale; @property (nonatomic, assign, readonly) CGPoint contentOffset; @property (nonatomic, assign, readonly) UIEdgeInsets contentInset; +@property (nonatomic, assign) BOOL disableKeyboardShortcuts; @end diff --git a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm index 03186710893e36..e9cbeef341556f 100644 --- a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm +++ b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm @@ -15,6 +15,8 @@ @implementation RCTUITextField { RCTBackedTextFieldDelegateAdapter *_textInputDelegateAdapter; NSDictionary *_defaultTextAttributes; + NSArray *_initialValueLeadingBarButtonGroups; + NSArray *_initialValueTrailingBarButtonGroups; } - (instancetype)initWithFrame:(CGRect)frame @@ -27,6 +29,8 @@ - (instancetype)initWithFrame:(CGRect)frame _textInputDelegateAdapter = [[RCTBackedTextFieldDelegateAdapter alloc] initWithTextField:self]; _scrollEnabled = YES; + _initialValueLeadingBarButtonGroups = nil; + _initialValueTrailingBarButtonGroups = nil; } return self; @@ -115,6 +119,26 @@ - (void)setSecureTextEntry:(BOOL)secureTextEntry self.attributedText = originalText; } +- (void)setDisableKeyboardShortcuts:(BOOL)disableKeyboardShortcuts +{ + // Initialize the initial values only once + if (_initialValueLeadingBarButtonGroups == nil) { + // Capture initial values of leading and trailing button groups + _initialValueLeadingBarButtonGroups = self.inputAssistantItem.leadingBarButtonGroups; + _initialValueTrailingBarButtonGroups = self.inputAssistantItem.trailingBarButtonGroups; + } + + if (disableKeyboardShortcuts) { + self.inputAssistantItem.leadingBarButtonGroups = @[]; + self.inputAssistantItem.trailingBarButtonGroups = @[]; + } else { + // Restore the initial values + self.inputAssistantItem.leadingBarButtonGroups = _initialValueLeadingBarButtonGroups; + self.inputAssistantItem.trailingBarButtonGroups = _initialValueTrailingBarButtonGroups; + } + _disableKeyboardShortcuts = disableKeyboardShortcuts; +} + #pragma mark - Placeholder - (NSDictionary *)_placeholderTextAttributes diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index d974a6b27bd338..b56956a0396f5b 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -3026,6 +3026,7 @@ export type enterKeyHintType = | \\"send\\"; type PasswordRules = string; type IOSProps = $ReadOnly<{| + disableKeyboardShortcuts?: ?boolean, clearButtonMode?: ?(\\"never\\" | \\"while-editing\\" | \\"unless-editing\\" | \\"always\\"), clearTextOnFocus?: ?boolean, dataDetectorTypes?: @@ -3379,6 +3380,7 @@ export type enterKeyHintType = | \\"enter\\"; type PasswordRules = string; type IOSProps = $ReadOnly<{| + disableKeyboardShortcuts?: ?boolean, clearButtonMode?: ?(\\"never\\" | \\"while-editing\\" | \\"unless-editing\\" | \\"always\\"), clearTextOnFocus?: ?boolean, dataDetectorTypes?: diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index d2ace9416ecd3a..faa494a615dc0f 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -285,6 +285,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _backedTextInputView.inputAccessoryViewButtonLabel = RCTNSStringFromString(newTextInputProps.inputAccessoryViewButtonLabel); } + + if (newTextInputProps.disableKeyboardShortcuts != oldTextInputProps.disableKeyboardShortcuts) { + _backedTextInputView.disableKeyboardShortcuts = newTextInputProps.disableKeyboardShortcuts; + } + [super updateProps:props oldProps:oldProps]; [self setDefaultInputAccessoryView]; diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm index 2b0278d31b757c..6e562ada332f74 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm @@ -45,6 +45,7 @@ void RCTCopyBackedTextInput( toTextInput.textContentType = fromTextInput.textContentType; toTextInput.smartInsertDeleteType = fromTextInput.smartInsertDeleteType; toTextInput.passwordRules = fromTextInput.passwordRules; + toTextInput.disableKeyboardShortcuts = fromTextInput.disableKeyboardShortcuts; [toTextInput setSelectedTextRange:fromTextInput.selectedTextRange notifyDelegate:NO]; } diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp index cec8d245f4fc7e..8da72b43062000 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp @@ -126,6 +126,12 @@ BaseTextInputProps::BaseTextInputProps( rawProps, "multiline", sourceProps.multiline, + {false})), + disableKeyboardShortcuts(convertRawProp( + context, + rawProps, + "disableKeyboardShortcuts", + sourceProps.disableKeyboardShortcuts, {false})) {} void BaseTextInputProps::setProp( @@ -208,6 +214,7 @@ void BaseTextInputProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(readOnly); RAW_SET_PROP_SWITCH_CASE_BASIC(submitBehavior); RAW_SET_PROP_SWITCH_CASE_BASIC(multiline); + RAW_SET_PROP_SWITCH_CASE_BASIC(disableKeyboardShortcuts); } } diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.h b/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.h index 37d520f8bb1f89..6da0c69accbc1a 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.h @@ -80,6 +80,8 @@ class BaseTextInputProps : public ViewProps, public BaseTextProps { SubmitBehavior submitBehavior{SubmitBehavior::Default}; bool multiline{false}; + + bool disableKeyboardShortcuts{false}; }; } // namespace facebook::react diff --git a/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js b/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js index 4facb47446ba5f..97df72ce79ab54 100644 --- a/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js +++ b/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js @@ -296,6 +296,73 @@ const TextInputWithFocusButton = () => { ); }; +function KeyboardShortcutsExample() { + return ( + + + Single line: + + + + + + + + + + + + Multiline: + + + + + + + ); +} + const styles = StyleSheet.create({ multiline: { height: 50, @@ -938,6 +1005,12 @@ const textInputExamples: Array = [ return ; }, }, + { + title: 'Keyboard shortcuts', + render: function (): React.Node { + return ; + }, + }, { title: 'Line Break Mode', render: function (): React.Node {