/** * Copyright (c) 2015-present, Facebook, Inc. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. */ #import "RCTBaseTextInputView.h" #import #import #import #import #import #import #import #import "RCTTextAttributes.h" #import "RCTTextSelection.h" @implementation RCTBaseTextInputView { __weak RCTBridge *_bridge; __weak RCTEventDispatcher *_eventDispatcher; BOOL _hasInputAccesoryView; NSString *_Nullable _predictedText; NSInteger _nativeEventCount; } - (instancetype)initWithBridge:(RCTBridge *)bridge { RCTAssertParam(bridge); if (self = [super initWithFrame:CGRectZero]) { _bridge = bridge; _eventDispatcher = bridge.eventDispatcher; } return self; } RCT_NOT_IMPLEMENTED(- (instancetype)init) RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)decoder) RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame) - (UIView *)backedTextInputView { RCTAssert(NO, @"-[RCTBaseTextInputView backedTextInputView] must be implemented in subclass."); return nil; } #pragma mark - RCTComponent - (void)didUpdateReactSubviews { // Do nothing. } #pragma mark - Properties - (void)setTextAttributes:(RCTTextAttributes *)textAttributes { _textAttributes = textAttributes; [self enforceTextAttributesIfNeeded]; } - (void)enforceTextAttributesIfNeeded { id backedTextInputView = self.backedTextInputView; if (backedTextInputView.attributedText.string.length != 0) { return; } backedTextInputView.font = _textAttributes.effectiveFont; backedTextInputView.textColor = _textAttributes.effectiveForegroundColor; backedTextInputView.textAlignment = _textAttributes.alignment; } - (void)setReactPaddingInsets:(UIEdgeInsets)reactPaddingInsets { _reactPaddingInsets = reactPaddingInsets; // We apply `paddingInsets` as `backedTextInputView`'s `textContainerInset`. self.backedTextInputView.textContainerInset = reactPaddingInsets; [self setNeedsLayout]; } - (void)setReactBorderInsets:(UIEdgeInsets)reactBorderInsets { _reactBorderInsets = reactBorderInsets; // We apply `borderInsets` as `backedTextInputView` layout offset. self.backedTextInputView.frame = UIEdgeInsetsInsetRect(self.bounds, reactBorderInsets); [self setNeedsLayout]; } - (NSAttributedString *)attributedText { return self.backedTextInputView.attributedText; } - (void)setAttributedText:(NSAttributedString *)attributedText { NSInteger eventLag = _nativeEventCount - _mostRecentEventCount; if (eventLag == 0 && ![attributedText isEqualToAttributedString:self.backedTextInputView.attributedText]) { UITextRange *selection = self.backedTextInputView.selectedTextRange; NSInteger oldTextLength = self.backedTextInputView.attributedText.string.length; self.backedTextInputView.attributedText = attributedText; if (selection.empty) { // Maintaining a cursor position relative to the end of the old text. NSInteger offsetStart = [self.backedTextInputView offsetFromPosition:self.backedTextInputView.beginningOfDocument toPosition:selection.start]; NSInteger offsetFromEnd = oldTextLength - offsetStart; NSInteger newOffset = attributedText.string.length - offsetFromEnd; UITextPosition *position = [self.backedTextInputView positionFromPosition:self.backedTextInputView.beginningOfDocument offset:newOffset]; [self.backedTextInputView setSelectedTextRange:[self.backedTextInputView textRangeFromPosition:position toPosition:position] notifyDelegate:YES]; } [self updateLocalData]; } else if (eventLag > RCTTextUpdateLagWarningThreshold) { RCTLogWarn(@"Native TextInput(%@) is %lld events ahead of JS - try to make your JS faster.", self.backedTextInputView.attributedText.string, (long long)eventLag); } } - (RCTTextSelection *)selection { id backedTextInputView = self.backedTextInputView; UITextRange *selectedTextRange = backedTextInputView.selectedTextRange; return [[RCTTextSelection new] initWithStart:[backedTextInputView offsetFromPosition:backedTextInputView.beginningOfDocument toPosition:selectedTextRange.start] end:[backedTextInputView offsetFromPosition:backedTextInputView.beginningOfDocument toPosition:selectedTextRange.end]]; } - (void)setSelection:(RCTTextSelection *)selection { if (!selection) { return; } id backedTextInputView = self.backedTextInputView; UITextRange *previousSelectedTextRange = backedTextInputView.selectedTextRange; UITextPosition *start = [backedTextInputView positionFromPosition:backedTextInputView.beginningOfDocument offset:selection.start]; UITextPosition *end = [backedTextInputView positionFromPosition:backedTextInputView.beginningOfDocument offset:selection.end]; UITextRange *selectedTextRange = [backedTextInputView textRangeFromPosition:start toPosition:end]; NSInteger eventLag = _nativeEventCount - _mostRecentEventCount; if (eventLag == 0 && ![previousSelectedTextRange isEqual:selectedTextRange]) { [backedTextInputView setSelectedTextRange:selectedTextRange notifyDelegate:NO]; } else if (eventLag > RCTTextUpdateLagWarningThreshold) { RCTLogWarn(@"Native TextInput(%@) is %lld events ahead of JS - try to make your JS faster.", backedTextInputView.attributedText.string, (long long)eventLag); } } #pragma mark - RCTBackedTextInputDelegate - (BOOL)textInputShouldBeginEditing { return YES; } - (void)textInputDidBeginEditing { if (_clearTextOnFocus) { self.backedTextInputView.attributedText = [NSAttributedString new]; } if (_selectTextOnFocus) { [self.backedTextInputView selectAll:nil]; } [_eventDispatcher sendTextEventWithType:RCTTextEventTypeFocus reactTag:self.reactTag text:self.backedTextInputView.attributedText.string key:nil eventCount:_nativeEventCount]; } - (BOOL)textInputShouldEndEditing { return YES; } - (void)textInputDidEndEditing { [_eventDispatcher sendTextEventWithType:RCTTextEventTypeEnd reactTag:self.reactTag text:self.backedTextInputView.attributedText.string key:nil eventCount:_nativeEventCount]; [_eventDispatcher sendTextEventWithType:RCTTextEventTypeBlur reactTag:self.reactTag text:self.backedTextInputView.attributedText.string key:nil eventCount:_nativeEventCount]; } - (BOOL)textInputShouldReturn { // We send `submit` event here, in `textInputShouldReturn` // (not in `textInputDidReturn)`, because of semantic of the event: // `onSubmitEditing` is called when "Submit" button // (the blue key on onscreen keyboard) did pressed // (no connection to any specific "submitting" process). [_eventDispatcher sendTextEventWithType:RCTTextEventTypeSubmit reactTag:self.reactTag text:self.backedTextInputView.attributedText.string key:nil eventCount:_nativeEventCount]; return _blurOnSubmit; } - (void)textInputDidReturn { // Does nothing. } - (BOOL)textInputShouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text { id backedTextInputView = self.backedTextInputView; if (!backedTextInputView.textWasPasted) { [_eventDispatcher sendTextEventWithType:RCTTextEventTypeKeyPress reactTag:self.reactTag text:nil key:text eventCount:_nativeEventCount]; } if (_maxLength) { NSUInteger allowedLength = _maxLength.integerValue - backedTextInputView.attributedText.string.length + range.length; if (text.length > allowedLength) { // If we typed/pasted more than one character, limit the text inputted. if (text.length > 1) { // Truncate the input string so the result is exactly maxLength NSString *limitedString = [text substringToIndex:allowedLength]; NSMutableAttributedString *newAttributedText = [backedTextInputView.attributedText mutableCopy]; [newAttributedText replaceCharactersInRange:range withString:limitedString]; backedTextInputView.attributedText = newAttributedText; _predictedText = newAttributedText.string; // Collapse selection at end of insert to match normal paste behavior. UITextPosition *insertEnd = [backedTextInputView positionFromPosition:backedTextInputView.beginningOfDocument offset:(range.location + allowedLength)]; [backedTextInputView setSelectedTextRange:[backedTextInputView textRangeFromPosition:insertEnd toPosition:insertEnd] notifyDelegate:YES]; [self textInputDidChange]; } return NO; } } if (range.location + range.length > _predictedText.length) { // _predictedText got out of sync in a bad way, so let's just force sync it. Haven't been able to repro this, but // it's causing a real crash here: #6523822 _predictedText = backedTextInputView.attributedText.string; } NSString *previousText = [_predictedText substringWithRange:range] ?: @""; if (_predictedText) { _predictedText = [_predictedText stringByReplacingCharactersInRange:range withString:text]; } else { _predictedText = text; } if (_onTextInput) { _onTextInput(@{ @"text": text, @"previousText": previousText, @"range": @{ @"start": @(range.location), @"end": @(range.location + range.length) }, @"eventCount": @(_nativeEventCount), }); } return YES; } - (void)textInputDidChange { [self updateLocalData]; id backedTextInputView = self.backedTextInputView; // Detect when `backedTextInputView` updates happend that didn't invoke `shouldChangeTextInRange` // (e.g. typing simplified chinese in pinyin will insert and remove spaces without // calling shouldChangeTextInRange). This will cause JS to get out of sync so we // update the mismatched range. NSRange currentRange; NSRange predictionRange; if (findMismatch(backedTextInputView.attributedText.string, _predictedText, ¤tRange, &predictionRange)) { NSString *replacement = [backedTextInputView.attributedText.string substringWithRange:currentRange]; [self textInputShouldChangeTextInRange:predictionRange replacementText:replacement]; // JS will assume the selection changed based on the location of our shouldChangeTextInRange, so reset it. [self textInputDidChangeSelection]; _predictedText = backedTextInputView.attributedText.string; } _nativeEventCount++; if (_onChange) { _onChange(@{ @"text": self.attributedText.string, @"target": self.reactTag, @"eventCount": @(_nativeEventCount), }); } } - (void)textInputDidChangeSelection { if (!_onSelectionChange) { return; } RCTTextSelection *selection = self.selection; _onSelectionChange(@{ @"selection": @{ @"start": @(selection.start), @"end": @(selection.end), }, }); } - (void)updateLocalData { [self enforceTextAttributesIfNeeded]; [_bridge.uiManager setLocalData:[self.backedTextInputView.attributedText copy] forView:self]; } #pragma mark - Layout (in UIKit terms, with all insets) - (CGSize)intrinsicContentSize { CGSize size = self.backedTextInputView.intrinsicContentSize; size.width += _reactBorderInsets.left + _reactBorderInsets.right; size.height += _reactBorderInsets.top + _reactBorderInsets.bottom; // Returning value DOES include border and padding insets. return size; } - (CGSize)sizeThatFits:(CGSize)size { CGFloat compoundHorizontalBorderInset = _reactBorderInsets.left + _reactBorderInsets.right; CGFloat compoundVerticalBorderInset = _reactBorderInsets.top + _reactBorderInsets.bottom; size.width -= compoundHorizontalBorderInset; size.height -= compoundVerticalBorderInset; // Note: `paddingInsets` was already included in `backedTextInputView` size // because it was applied as `textContainerInset`. CGSize fittingSize = [self.backedTextInputView sizeThatFits:size]; fittingSize.width += compoundHorizontalBorderInset; fittingSize.height += compoundVerticalBorderInset; // Returning value DOES include border and padding insets. return fittingSize; } #pragma mark - Accessibility - (UIView *)reactAccessibilityElement { return self.backedTextInputView; } #pragma mark - Focus Control - (void)reactFocus { [self.backedTextInputView reactFocus]; } - (void)reactBlur { [self.backedTextInputView reactBlur]; } - (void)didMoveToWindow { [self.backedTextInputView reactFocusIfNeeded]; } #pragma mark - Custom Input Accessory View - (void)didSetProps:(NSArray *)changedProps { [self invalidateInputAccessoryView]; } - (void)invalidateInputAccessoryView { #if !TARGET_OS_TV UIView *textInputView = self.backedTextInputView; UIKeyboardType keyboardType = textInputView.keyboardType; // These keyboard types (all are number pads) don't have a "Done" button by default, // so we create an `inputAccessoryView` with this button for them. BOOL shouldHaveInputAccesoryView = ( keyboardType == UIKeyboardTypeNumberPad || keyboardType == UIKeyboardTypePhonePad || keyboardType == UIKeyboardTypeDecimalPad || keyboardType == UIKeyboardTypeASCIICapableNumberPad ) && textInputView.returnKeyType == UIReturnKeyDone; if (_hasInputAccesoryView == shouldHaveInputAccesoryView) { return; } _hasInputAccesoryView = shouldHaveInputAccesoryView; if (shouldHaveInputAccesoryView) { UIToolbar *toolbarView = [[UIToolbar alloc] init]; [toolbarView sizeToFit]; UIBarButtonItem *flexibleSpace = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil]; UIBarButtonItem *doneButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(handleInputAccessoryDoneButton)]; toolbarView.items = @[flexibleSpace, doneButton]; textInputView.inputAccessoryView = toolbarView; } else { textInputView.inputAccessoryView = nil; } // We have to call `reloadInputViews` for focused text inputs to update an accessory view. if (textInputView.isFirstResponder) { [textInputView reloadInputViews]; } #endif } - (void)handleInputAccessoryDoneButton { if ([self textInputShouldReturn]) { [self.backedTextInputView endEditing:YES]; } } #pragma mark - Helpers static BOOL findMismatch(NSString *first, NSString *second, NSRange *firstRange, NSRange *secondRange) { NSInteger firstMismatch = -1; for (NSUInteger ii = 0; ii < MAX(first.length, second.length); ii++) { if (ii >= first.length || ii >= second.length || [first characterAtIndex:ii] != [second characterAtIndex:ii]) { firstMismatch = ii; break; } } if (firstMismatch == -1) { return NO; } NSUInteger ii = second.length; NSUInteger lastMismatch = first.length; while (ii > firstMismatch && lastMismatch > firstMismatch) { if ([first characterAtIndex:(lastMismatch - 1)] != [second characterAtIndex:(ii - 1)]) { break; } ii--; lastMismatch--; } *firstRange = NSMakeRange(firstMismatch, lastMismatch - firstMismatch); *secondRange = NSMakeRange(firstMismatch, ii - firstMismatch); return YES; } @end