Skip to content

[iOS/macOS] [TextInput] Implement ghost text #1897

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 2, 2023
Merged
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 @@ -23,7 +23,7 @@ type NativeType = HostComponent<mixed>;
type NativeCommands = TextInputNativeCommands<NativeType>;

export const Commands: NativeCommands = codegenNativeCommands<NativeCommands>({
supportedCommands: ['focus', 'blur', 'setTextAndSelection'],
supportedCommands: ['focus', 'blur', 'setTextAndSelection', 'setGhostText'], // [macOS]
});

export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type NativeType = HostComponent<mixed>;
type NativeCommands = TextInputNativeCommands<NativeType>;

export const Commands: NativeCommands = codegenNativeCommands<NativeCommands>({
supportedCommands: ['focus', 'blur', 'setTextAndSelection'],
supportedCommands: ['focus', 'blur', 'setTextAndSelection', 'setGhostText'], // [macOS]
});

export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
Expand Down
1 change: 1 addition & 0 deletions Libraries/Components/TextInput/TextInput.flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,7 @@ type ImperativeMethods = $ReadOnly<{|
isFocused: () => boolean,
getNativeRef: () => ?React.ElementRef<HostComponent<mixed>>,
setSelection: (start: number, end: number) => void,
setGhostText: (ghostText: ?string) => void, // [macOS]
|}>;

/**
Expand Down
8 changes: 8 additions & 0 deletions Libraries/Components/TextInput/TextInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type TextInputInstance = React.ElementRef<HostComponent<mixed>> & {
+isFocused: () => boolean,
+getNativeRef: () => ?React.ElementRef<HostComponent<mixed>>,
+setSelection: (start: number, end: number) => void,
+setGhostText: (ghostText: ?string) => void, // [macOS]
};

let AndroidTextInput;
Expand Down Expand Up @@ -1408,6 +1409,13 @@ function InternalTextInput(props: Props): React.Node {
);
}
},
// [macOS
setGhostText(ghostText: ?string): void {
if (inputRef.current != null) {
viewCommands.setGhostText(inputRef.current, ghostText);
}
},
// macOS]
});
}
},
Expand Down
14 changes: 13 additions & 1 deletion Libraries/Components/TextInput/TextInputNativeCommands.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,20 @@ export interface TextInputNativeCommands<T> {
start: Int32,
end: Int32,
) => void;
// [macOS
// NYI on Android
+setGhostText: (
viewRef: React.ElementRef<T>,
value: ?string, // in theory this is nullable
) => void;
// macOS]
}

const supportedCommands = ['focus', 'blur', 'setTextAndSelection'];
const supportedCommands = [
'focus',
'blur',
'setTextAndSelection',
'setGhostText',
]; // [macOS]

export default supportedCommands;
2 changes: 2 additions & 0 deletions Libraries/Text/TextInput/Multiline/RCTUITextView.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ NS_ASSUME_NONNULL_BEGIN
- (void)setReadablePasteBoardTypes:(NSArray<NSPasteboardType> *)readablePasteboardTypes;
#endif // macOS]

@property (nonatomic, getter=isGhostTextChanging) BOOL ghostTextChanging; // [macOS]

@end

NS_ASSUME_NONNULL_END
5 changes: 4 additions & 1 deletion Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,10 @@ - (void)textViewDidChangeSelection:(__unused UITextView *)textView
{
if (![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
[self textViewDidChange:_backedTextInputView];
_ignoreNextTextInputCall = YES;

if (![_backedTextInputView isGhostTextChanging]) { // [macOS]
_ignoreNextTextInputCall = YES;
} // [macOS]
}
[self textViewProbablyDidChangeSelection];
}
Expand Down
2 changes: 2 additions & 0 deletions Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ NS_ASSUME_NONNULL_BEGIN
// Use `attributedText.string` instead.
@property (nonatomic, copy, nullable) NSString *text NS_UNAVAILABLE;

@property (nonatomic, getter=isGhostTextChanging) BOOL ghostTextChanging; // [macOS]

@end

NS_ASSUME_NONNULL_END
2 changes: 2 additions & 0 deletions Libraries/Text/TextInput/RCTBaseTextInputView.h
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, assign) BOOL showSoftInputOnFocus;
#endif // [macOS]

@property (nonatomic, copy, nullable) NSString *ghostText; // [macOS]

/**
Sets selection intext input if both start and end are within range of the text input.
**/
Expand Down
123 changes: 110 additions & 13 deletions Libraries/Text/TextInput/RCTBaseTextInputView.m
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
@implementation RCTBaseTextInputView {
__weak RCTBridge *_bridge;
__weak id<RCTEventDispatcherProtocol> _eventDispatcher;

NSInteger _ghostTextPosition; // [macOS] only valid if _ghostText != nil

BOOL _hasInputAccesoryView;
// [macOS] remove explicit _predictedText ivar declaration
BOOL _didMoveToWindow;
Expand Down Expand Up @@ -164,7 +167,7 @@ - (void)setAttributedText:(NSAttributedString *)attributedText

textNeedsUpdate = ([self textOf:attributedTextCopy equals:backedTextInputViewTextCopy] == NO);

if (eventLag == 0 && textNeedsUpdate) {
if ((eventLag == 0 || self.backedTextInputView.ghostTextChanging) && textNeedsUpdate) { // [macOS]
#if !TARGET_OS_OSX // [macOS]
UITextRange *selection = self.backedTextInputView.selectedTextRange;
#else // [macOS
Expand All @@ -191,7 +194,7 @@ - (void)setAttributedText:(NSAttributedString *)attributedText
[self.backedTextInputView positionFromPosition:self.backedTextInputView.beginningOfDocument offset:newOffset];
[self.backedTextInputView setSelectedTextRange:[self.backedTextInputView textRangeFromPosition:position
toPosition:position]
notifyDelegate:YES];
notifyDelegate:!self.backedTextInputView.ghostTextChanging]; // [macOS]
}
#else // [macOS
if (selection.length == 0) {
Expand All @@ -200,7 +203,7 @@ - (void)setAttributedText:(NSAttributedString *)attributedText
NSInteger offsetFromEnd = oldTextLength - start;
NSInteger newOffset = self.backedTextInputView.attributedText.length - offsetFromEnd;
[self.backedTextInputView setSelectedTextRange:NSMakeRange(newOffset, 0)
notifyDelegate:YES];
notifyDelegate:!self.backedTextInputView.ghostTextChanging];
}
#endif // macOS]

Expand Down Expand Up @@ -421,6 +424,8 @@ - (BOOL)textInputShouldEndEditing

- (void)textInputDidEndEditing
{
self.ghostText = nil; // [macOS]

[_eventDispatcher sendTextEventWithType:RCTTextEventTypeEnd
reactTag:self.reactTag
text:[self.backedTextInputView.attributedText.string copy]
Expand Down Expand Up @@ -530,6 +535,8 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range
{
id<RCTBackedTextInputViewProtocol> backedTextInputView = self.backedTextInputView;

self.ghostText = nil; // [macOS]

if (!backedTextInputView.textWasPasted) {
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeKeyPress
reactTag:self.reactTag
Expand Down Expand Up @@ -596,7 +603,7 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range
withString:text];
}

if (_onTextInput) {
if (_onTextInput && !self.backedTextInputView.ghostTextChanging) { // [macOS]
_onTextInput(@{
// We copy the string here because if it's a mutable string it may get released before we stop using it on a
// different thread, causing a crash.
Expand Down Expand Up @@ -630,20 +637,24 @@ - (void)textInputDidChange
[self setPredictedText:backedTextInputView.attributedText.string]; // [macOS]
}

_nativeEventCount++;
if (!self.backedTextInputView.ghostTextChanging) { // [macOS]
_nativeEventCount++;

if (_onChange) {
_onChange(@{
@"text" : [self.attributedText.string copy],
@"target" : self.reactTag,
@"eventCount" : @(_nativeEventCount),
});
}
if (_onChange) {
_onChange(@{
@"text" : [self.attributedText.string copy],
@"target" : self.reactTag,
@"eventCount" : @(_nativeEventCount),
});
}
} // [macOS]
}

- (void)textInputDidChangeSelection
{
if (!_onSelectionChange) {
self.ghostText = nil; // [macOS]

if (!_onSelectionChange || self.backedTextInputView.ghostTextChanging) { // [macOS]
return;
}

Expand Down Expand Up @@ -881,6 +892,92 @@ - (void)handleInputAccessoryDoneButton
}
#endif // [macOS]

// [macOS

- (NSDictionary<NSAttributedStringKey, id> *)ghostTextAttributes
{
RCTUIView<RCTBackedTextInputViewProtocol> *backedTextInputView = self.backedTextInputView;
NSMutableDictionary<NSAttributedStringKey, id> *textAttributes =
[backedTextInputView.defaultTextAttributes mutableCopy] ?: [NSMutableDictionary new];

if (@available(iOS 13.0, *)) {
[textAttributes setValue:backedTextInputView.placeholderColor ?: [RCTUIColor placeholderTextColor]
forKey:NSForegroundColorAttributeName];
} else {
if (backedTextInputView.placeholderColor) {
[textAttributes setValue:backedTextInputView.placeholderColor forKey:NSForegroundColorAttributeName];
} else {
[textAttributes removeObjectForKey:NSForegroundColorAttributeName];
}
}

return textAttributes;
}

- (void)setGhostText:(NSString *)ghostText {
RCTTextSelection *selection = self.selection;
NSString *newGhostText = ghostText.length > 0 ? ghostText : nil;

if (selection.start != selection.end) {
newGhostText = nil;
}

if ((_ghostText == nil && newGhostText == nil) || [_ghostText isEqual:newGhostText]) {
return;
}

if (self.backedTextInputView.ghostTextChanging) {
// look out for nested callbacks -- this can happen for example when selection changes in response to
// attributed text changing. Such callbacks are initiated by Apple, or we could suppress this other ways.
return;
}

self.backedTextInputView.ghostTextChanging = YES;

if (_ghostText != nil) {
BOOL shouldDeleteGhostText = YES;
NSRange ghostTextRange = NSMakeRange(_ghostTextPosition, _ghostText.length);
NSMutableAttributedString *attributedString = [self.attributedText mutableCopy];

if ([attributedString length] < NSMaxRange(ghostTextRange)) {
RCTAssert(false, @"Ghost text not fully present in text view text");
shouldDeleteGhostText = NO;
}

NSString *actualGhostText = shouldDeleteGhostText
? [[attributedString attributedSubstringFromRange:ghostTextRange] string]
: nil;

if (![actualGhostText isEqual:_ghostText]) {
RCTAssert(false, @"Ghost text does not match text view text");
shouldDeleteGhostText = NO;
}

if (shouldDeleteGhostText) {
[attributedString deleteCharactersInRange:ghostTextRange];
self.attributedText = attributedString;
[self setSelectionStart:selection.start selectionEnd:selection.end];
}
}

_ghostText = [newGhostText copy];
_ghostTextPosition = selection.start;

if (_ghostText != nil) {
NSMutableAttributedString *attributedString = [self.attributedText mutableCopy];
NSAttributedString *ghostAttributedString = [[NSAttributedString alloc] initWithString:_ghostText
attributes:self.ghostTextAttributes];

[attributedString insertAttributedString:ghostAttributedString atIndex:_ghostTextPosition];
self.attributedText = attributedString;
[self setSelectionStart:_ghostTextPosition selectionEnd:_ghostTextPosition];
}

self.backedTextInputView.ghostTextChanging = NO;
}

// macOS]

#pragma mark - Helpers

static BOOL findMismatch(NSString *first, NSString *second, NSRange *firstRange, NSRange *secondRange)
Expand Down
11 changes: 11 additions & 0 deletions Libraries/Text/TextInput/RCTBaseTextInputViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,17 @@ - (void)setBridge:(RCTBridge *)bridge
}];
}

// [macOS
RCT_EXPORT_METHOD(setGhostText
:(nonnull NSNumber *)reactTag
:(NSString *)text) {

[self.bridge.uiManager addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary<NSNumber *, RCTPlatformView *> *viewRegistry) {
[(RCTBaseTextInputView *)viewRegistry[reactTag] setGhostText:text];
}];
}
// macOS]

#pragma mark - RCTUIManagerObserver

- (void)uiManagerWillPerformMounting:(__unused RCTUIManager *)uiManager
Expand Down
2 changes: 2 additions & 0 deletions Libraries/Text/TextInput/Singleline/RCTUITextField.h
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, assign) CGFloat pointScaleFactor;
#endif // macOS]

@property (nonatomic, getter=isGhostTextChanging) BOOL ghostTextChanging; // [macOS]

@end

NS_ASSUME_NONNULL_END
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
private static final int BLUR_TEXT_INPUT = 2;
private static final int SET_MOST_RECENT_EVENT_COUNT = 3;
private static final int SET_TEXT_AND_SELECTION = 4;
private static final int SET_GHOST_TEXT = 5; // [macOS]

private static final int INPUT_TYPE_KEYBOARD_NUMBER_PAD = InputType.TYPE_CLASS_NUMBER;
private static final int INPUT_TYPE_KEYBOARD_DECIMAL_PAD =
Expand Down Expand Up @@ -278,7 +279,7 @@ public Map<String, Object> getExportedCustomDirectEventTypeConstants() {

@Override
public @Nullable Map<String, Integer> getCommandsMap() {
return MapBuilder.of("focusTextInput", FOCUS_TEXT_INPUT, "blurTextInput", BLUR_TEXT_INPUT);
return MapBuilder.of("focusTextInput", FOCUS_TEXT_INPUT, "blurTextInput", BLUR_TEXT_INPUT, "setGhostText", SET_GHOST_TEXT); // [macOS]
}

@Override
Expand All @@ -297,6 +298,11 @@ public void receiveCommand(
case SET_TEXT_AND_SELECTION:
this.receiveCommand(reactEditText, "setTextAndSelection", args);
break;
// [macOS
case SET_GHOST_TEXT:
this.receiveCommand(reactEditText, "setGhostText", args);
break;
// macOS]
}
}

Expand Down Expand Up @@ -331,6 +337,12 @@ public void receiveCommand(
}
reactEditText.maybeSetSelection(mostRecentEventCount, start, end);
break;
// [macOS
case "setGhostText":
default:
throw new IllegalArgumentException(
"Unsupported command setGhostText received by ReactTextInputManager.");
// macOS]
}
}

Expand Down
Loading