Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ @implementation RCTTextInputComponentView {
*/
NSDictionary<NSAttributedStringKey, id> *_originalTypingAttributes;

/*
* While JS's text is written into the field, UIKit can deliver the keys queued behind `-setAttributedText:` inside
* the write, and the write then replaces them. `_textBeforeUpdateFromJS` is the field's text before the write (nil
* outside one). A key that goes through -textInputShouldChangeText:inRange: during the write sets
* `_keyTypedDuringUpdateFromJS`, and the change UIKit reports next is recorded in `_textTypedDuringUpdateFromJS`
* (and the selection it left in `_selectionTypedDuringUpdateFromJS`).
* Other changes reported during the write are the write's own (a text view reports it). A key that leaves marked
* text (IME composition) sets `_markedTextDuringUpdateFromJS`: composition is not put back as plain text.
* See -_setAttributedStringFromJS: and -_restoreTextTypedDuringUpdateFromJS:writtenText:.
*/
NSString *_textBeforeUpdateFromJS;
NSString *_textTypedDuringUpdateFromJS;
AttributedString::Range _selectionTypedDuringUpdateFromJS;
BOOL _keyTypedDuringUpdateFromJS;
BOOL _markedTextDuringUpdateFromJS;
/*
* While -_restoreTextTypedDuringUpdateFromJS:writtenText: puts keys back, the range its edit replaces and the length
* it adds (both 0 outside it). UIKit can deliver more queued keys inside that edit, before it applies: their
* `maxLength` check counts that length, and a key that edits text before the end of that range is dropped (see
* -textInputShouldChangeText:inRange:).
*/
NSRange _rangeBeingRestored;
NSInteger _lengthBeingRestored;

BOOL _hasInputAccessoryView;
CGSize _previousContentSize;
}
Expand Down Expand Up @@ -361,10 +385,127 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &
}

if (_mostRecentEventCount == _state->getData().mostRecentEventCount) {
_comingFromJS = YES;
[self _setAttributedString:RCTNSAttributedStringFromAttributedStringBox(data.attributedStringBox)];
_comingFromJS = NO;
[self _setAttributedStringFromJS:RCTNSAttributedStringFromAttributedStringBox(data.attributedStringBox)];
}
}

// Writes JS's text into the field and puts back the keys UIKit delivered during the write.
- (void)_setAttributedStringFromJS:(NSAttributedString *)attributedString
{
NSString *textBeforeUpdate = [self _beginUpdateFromJS];
[self _setAttributedString:attributedString];
_comingFromJS = NO;
[self _restoreTextTypedDuringUpdateFromJS:textBeforeUpdate writtenText:attributedString.string];
}

// Starts a write from JS (`_comingFromJS`): returns the field's text before it.
- (NSString *)_beginUpdateFromJS
{
_comingFromJS = YES;
_textBeforeUpdateFromJS = [_backedTextInputView.attributedText.string copy];
_textTypedDuringUpdateFromJS = nil;
_selectionTypedDuringUpdateFromJS = {};
_keyTypedDuringUpdateFromJS = NO;
_markedTextDuringUpdateFromJS = NO;
return _textBeforeUpdateFromJS;
}

- (void)_setSelectedRange:(AttributedString::Range)range
{
UITextPosition *start = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument
offset:range.location];
UITextPosition *end = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument
offset:range.location + range.length];
if (start != nil && end != nil) {
[_backedTextInputView setSelectedTextRange:[_backedTextInputView textRangeFromPosition:start toPosition:end]
notifyDelegate:NO];
}
}

- (void)_endUpdateFromJS
{
_textBeforeUpdateFromJS = nil;
_textTypedDuringUpdateFromJS = nil;
_selectionTypedDuringUpdateFromJS = {};
_keyTypedDuringUpdateFromJS = NO;
_markedTextDuringUpdateFromJS = NO;
}

// Applies the edit the user made during the write from JS (`textBeforeUpdate` -> the text the keys left, see
// RCTTextInputTextToRestore) to the text JS wrote, within `maxLength`, and reports it as typing (-textInputDidChange,
// then -textInputDidChangeSelection), so JS receives those keys and can still change or reject them. The selection is
// mapped through the edit: JS's selection (setTextAndSelection) or the caret the write restored. Nothing is put back
// while the user composes text (IME marked text): composition is not committed as plain text.
- (void)_restoreTextTypedDuringUpdateFromJS:(NSString *)textBeforeUpdate writtenText:(NSString *)writtenText
{
NSString *typedText = _textTypedDuringUpdateFromJS;
AttributedString::Range typedSelection = _selectionTypedDuringUpdateFromJS;
BOOL markedText = _markedTextDuringUpdateFromJS || _backedTextInputView.markedTextRange != nil;
[self _endUpdateFromJS];
if (typedText == nil || markedText) {
return;
}
if (![_backedTextInputView.attributedText.string isEqualToString:writtenText]) {
// The keys arrived after the write had replaced the text (a text view can deliver them there), so the field
// still has them: only report them. The write then put the caret back where it was before the keys: put it
// after them again.
if ([_backedTextInputView.attributedText.string isEqualToString:typedText]) {
[self _setSelectedRange:typedSelection];
}
[self textInputDidChange];
[self textInputDidChangeSelection];
return;
}
NSRange replaced;
NSString *inserted;
if (!RCTTextInputTextToRestore(textBeforeUpdate, typedText, writtenText, &replaced, &inserted)) {
return;
}
const auto &props = static_cast<const TextInputProps &>(*_props);
if (inserted.length > 0 && props.maxLength < std::numeric_limits<int>::max()) {
// UIKit checked these keys against the text before the write.
inserted = RCTTextInputTextWithinLength(
inserted, (NSInteger)props.maxLength - (NSInteger)writtenText.length + (NSInteger)replaced.length);
if (inserted == nil) {
return;
}
}

AttributedString::Range selection = [self _selectionRange];
UITextPosition *replacedStart = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument
offset:replaced.location];
UITextPosition *replacedEnd = [_backedTextInputView positionFromPosition:replacedStart offset:replaced.length];
UITextRange *replacedRange = [_backedTextInputView textRangeFromPosition:replacedStart toPosition:replacedEnd];
if (replacedRange == nil) {
return;
}
// Put the keys back as UIKit inserts typed text: as an edit the user can undo, with the attributes of JS's text
// before them (the typing attributes, set after the caret moved). UIKit can deliver more queued keys inside this
// edit, before it applies: the caret is put after `replacedRange`, so they land after it, stay in the field (only
// `replacedRange` is replaced) and are reported with it.
[_backedTextInputView setSelectedTextRange:[_backedTextInputView textRangeFromPosition:replacedEnd
toPosition:replacedEnd]
notifyDelegate:NO];
NSAttributedString *textBeforeRestore = _backedTextInputView.attributedText;
[self _updateTypingAttributesSampledAt:replaced.location];
NSInteger lengthAdded = (NSInteger)inserted.length - (NSInteger)replaced.length;
_comingFromJS = YES;
_rangeBeingRestored = replaced;
_lengthBeingRestored = lengthAdded;
[_backedTextInputView replaceRange:replacedRange withText:inserted];
_rangeBeingRestored = NSMakeRange(0, 0);
_lengthBeingRestored = 0;
// The keys UIKit delivered inside the edit follow the restored ones: the selection moves past them too.
NSInteger delivered =
(NSInteger)_backedTextInputView.attributedText.length - (NSInteger)textBeforeRestore.length - lengthAdded;
NSUInteger insertedLength = inserted.length + (NSUInteger)MAX(delivered, (NSInteger)0);
NSInteger start = RCTTextInputOffsetAfterEdit(selection.location, replaced, insertedLength);
NSInteger end = RCTTextInputOffsetAfterEdit(selection.location + selection.length, replaced, insertedLength);
[self _setSelectedRange:{.location = (int)start, .length = (int)(end - start)}];
[self _updateTypingAttributes];
_comingFromJS = NO;
[self textInputDidChange];
[self textInputDidChangeSelection];
}

- (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
Expand Down Expand Up @@ -466,26 +607,24 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range
}
}

NSString *allowedText = text;
if (props.maxLength < std::numeric_limits<int>::max()) {
NSInteger allowedLength = props.maxLength - _backedTextInputView.attributedText.string.length + range.length;

if (allowedLength > 0 && text.length > allowedLength) {
// make sure unicode characters that are longer than 16 bits (such as emojis) are not cut off
NSRange cutOffCharacterRange = [text rangeOfComposedCharacterSequenceAtIndex:allowedLength - 1];
if (cutOffCharacterRange.location + cutOffCharacterRange.length > allowedLength) {
// the character at the length limit takes more than 16bits, truncation should end at the character before
allowedLength = cutOffCharacterRange.location;
}
}

if (allowedLength <= 0) {
return nil;
}

return allowedLength > text.length ? text : [text substringToIndex:allowedLength];
allowedText = RCTTextInputTextWithinLength(
text,
(NSInteger)props.maxLength - (NSInteger)_backedTextInputView.attributedText.string.length -
_lengthBeingRestored + (NSInteger)range.length);
}

return text;
if (range.location < NSMaxRange(_rangeBeingRestored)) {
// UIKit delivers this key inside the edit that puts keys back, before it applies, and it edits text before the
// end of the range that edit replaces (a backspace): the edit would then replace other text. The key is dropped.
return nil;
}
if (_textBeforeUpdateFromJS != nil) {
// UIKit delivers this key during a write from JS: see -textInputDidChange.
_keyTypedDuringUpdateFromJS = YES;
}
return allowedText;
}

- (BOOL)textInputShouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
Expand All @@ -496,6 +635,14 @@ - (BOOL)textInputShouldChangeTextInRange:(NSRange)range replacementText:(NSStrin
- (void)textInputDidChange
{
if (_comingFromJS) {
// The change a key made during a write from JS (see `_textBeforeUpdateFromJS`). Other changes reported during the
// write are the write's own: a text view reports the text JS wrote.
if (_keyTypedDuringUpdateFromJS) {
_keyTypedDuringUpdateFromJS = NO;
_textTypedDuringUpdateFromJS = [_backedTextInputView.attributedText.string copy];
_selectionTypedDuringUpdateFromJS = [self _selectionRange];
_markedTextDuringUpdateFromJS = _markedTextDuringUpdateFromJS || _backedTextInputView.markedTextRange != nil;
}
return;
}

Expand Down Expand Up @@ -595,12 +742,14 @@ - (void)setTextAndSelection:(NSInteger)eventCount
if (_mostRecentEventCount != eventCount) {
return;
}
_comingFromJS = YES;
NSString *textBeforeUpdate = [self _beginUpdateFromJS];
NSString *writtenText = nil;
if (value && ![value isEqualToString:_backedTextInputView.attributedText.string]) {
NSAttributedString *attributedString =
[[NSAttributedString alloc] initWithString:value attributes:_backedTextInputView.defaultTextAttributes];
[self _setAttributedString:attributedString];
[self _updateState];
writtenText = attributedString.string;
}

UITextPosition *startPosition = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument
Expand All @@ -617,6 +766,13 @@ - (void)setTextAndSelection:(NSInteger)eventCount
[_backedTextInputView scrollRangeToVisible:NSMakeRange(offsetEnd, 0)];
}
_comingFromJS = NO;
// A controlled `value` that JS changed (e.g. a transform in onChangeText) is written here: put back the keys UIKit
// delivered during the write, keeping JS's selection.
if (writtenText != nil) {
[self _restoreTextTypedDuringUpdateFromJS:textBeforeUpdate writtenText:writtenText];
} else {
[self _endUpdateFromJS];
}
}

#pragma mark - Default input accessory view
Expand Down Expand Up @@ -826,8 +982,16 @@ - (void)_updateTypingAttributes
if (_backedTextInputView.attributedText.length > 0 && _backedTextInputView.selectedTextRange != nil) {
NSUInteger offsetStart = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument
toPosition:_backedTextInputView.selectedTextRange.start];
[self _updateTypingAttributesSampledAt:offsetStart];
}
}

NSUInteger samplePoint = offsetStart == 0 ? 0 : offsetStart - 1;
// The typing attributes for text inserted at `offset`: the attributes of the character before it, or after it at the
// start of the field. Used for the caret (-_updateTypingAttributes) and for keys put back after a write from JS.
- (void)_updateTypingAttributesSampledAt:(NSUInteger)offset
{
if (_backedTextInputView.attributedText.length > 0) {
NSUInteger samplePoint = offset == 0 ? 0 : offset - 1;
_backedTextInputView.typingAttributes = [_backedTextInputView.attributedText attributesAtIndex:samplePoint
effectiveRange:NULL];
}
Expand Down Expand Up @@ -900,8 +1064,21 @@ - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldTe
if (shouldFallbackToBareTextComparison) {
return [newText.string isEqualToString:oldText.string];
} else {
// Typed text carries attributes a state built from props (a controlled `value`) does not: the input's
// `defaultTextAttributes` (the first character typed into an empty field also gets their background color and
// event emitter) and a paragraph style, which is UIKit's default one or the one in its initial typing attributes
// (another line break strategy). Both are accepted.
const auto &props = static_cast<const TextInputProps &>(*_props);
NSMutableDictionary<NSAttributedStringKey, id> *defaultAttributes =
[_originalTypingAttributes mutableCopy] ?: [NSMutableDictionary new];
[defaultAttributes addEntriesFromDictionary:_backedTextInputView.defaultTextAttributes];
defaultAttributes[NSParagraphStyleAttributeName] =
_backedTextInputView.defaultTextAttributes[NSParagraphStyleAttributeName]
?: NSParagraphStyle.defaultParagraphStyle;
return RCTIsAttributedStringEffectivelySame(
newText, oldText, _originalTypingAttributes, static_cast<const TextInputProps &>(*_props).textAttributes);
newText, oldText, defaultAttributes, props.textAttributes, !props.multiline) ||
RCTIsAttributedStringEffectivelySame(
newText, oldText, _originalTypingAttributes, props.textAttributes, !props.multiline);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,42 @@ UITextInputPasswordRules *RCTUITextInputPasswordRulesFromString(const std::strin

UITextSmartInsertDeleteType RCTUITextSmartInsertDeleteTypeFromOptionalBool(std::optional<bool> smartInsertDelete);

/*
* The range of `oldText` that an edit replaced to give `newText`: what lies between their common prefix and their
* common suffix, widened so it does not split a composed character sequence (an emoji, a flag, a letter with
* combining marks). The inserted text is `newText` over the same location, `newText.length - oldText.length + length`
* long.
*/
NSRange RCTTextInputChangedRange(NSString *oldText, NSString *newText);

/*
* The user typed while JS's text was being written into a focused text input: UIKit delivers keys queued behind
* `-setAttributedText:` inside it, and the write replaces them. Given the input's text before the write
* (`textBeforeUpdate`), what it held with the keys typed during the write (`typedText`), and the text JS wrote
* (`writtenText`), sets the range of `writtenText` to replace and the text to put there, and returns YES; returns NO
* if there is nothing to put back. If JS changed only attributes, the user's edit applies where it was made. If JS
* changed the characters (e.g. upper-casing), only an insertion is put back, at the same distance from the end.
*/
BOOL RCTTextInputTextToRestore(
NSString *textBeforeUpdate,
NSString *typedText,
NSString *writtenText,
NSRange *range,
NSString *_Nullable *_Nonnull text);

/*
* `text` cut to `allowedLength` (`maxLength`) without splitting a composed character sequence, or nil if no
* character fits.
*/
NSString *_Nullable RCTTextInputTextWithinLength(NSString *text, NSInteger allowedLength);

/*
* Where `offset` in a text lies after `range` of it was replaced by `insertedLength` characters: an offset at or after
* the end of the range moves with the text after it, an offset inside the range or at its start moves to the end of
* the insertion (the caret of a user who typed over the range).
*/
NSInteger RCTTextInputOffsetAfterEdit(NSInteger offset, NSRange range, NSUInteger insertedLength);

#if !TARGET_OS_TV
UIDataDetectorTypes RCTUITextViewDataDetectorTypesFromStringVector(const std::vector<std::string> &dataDetectorTypes);
#endif
Expand Down
Loading
Loading