mirror of
https://github.com/zhigang1992/react-native.git
synced 2026-04-09 17:31:39 +08:00
Summary: iOS-specific. For languages with complex input (such as Japanese or Chinese), a user has to type multiple characters that are then merged into a single one. If `-[UITextView setAttributedString:]` is used while the user is still typing, it resets the input and characters are not being treated as typed together. This PR avoids calling this method if possible, replacing it by just copying the attributes if the string has not been changed. That preserves the state and user can continue to type Korean or Chinese characters. Fixes #19339 <!-- Required: Write your motivation here. If this PR fixes an issue, type "Fixes #issueNumber" to automatically close the issue when the PR is merged. --> <!-- Required: Write your test plan here. If you changed any code, please provide us with clear instructions on how you verified your changes work. Bonus points for screenshots and videos! --> Essentially, the steps to reproduce are described in [the issue](https://github.com/facebook/react-native/issues/19339): 1. Type some Korean characters in TextInput, such as "하늘" (buttons `ㅎ`,`ㅏ`,`ㄴ`,`ㅡ`,`ㄹ`). 2. Then move the cursor to the beginning of the text, type "파란" (buttons `ㅍ`,`ㅏ`,`ㄹ`,`ㅏ`,`ㄴ`) this time. **Behaviour before this fix (broken)** Actual text: `ㅍㅏㄹㅏㄴ하늘`. Expected text: `파란하늘`. Characters aren't combined properly.  **Behaviour after this fix (correct)** Actual text: `파란하늘`. Expected text: `파란하늘`. Characters are combined, the same behaviour is in vanilla iOS `UITextView`.  <!-- Does this PR require a documentation change? Create a PR at https://github.com/facebook/react-native-website and add a link to it here. --> <!-- Required. Help reviewers and the release process by writing your own release notes. See below for an example. --> [IOS] [BUGFIX] [TextView] - Fix Korean/Chinese/Japanese input for multiline TextView on iOS. <!-- **INTERNAL and MINOR tagged notes will not be included in the next version's final release notes.** CATEGORY [----------] TYPE [ CLI ] [-------------] LOCATION [ DOCS ] [ BREAKING ] [-------------] [ GENERAL ] [ BUGFIX ] [ {Component} ] [ INTERNAL ] [ ENHANCEMENT ] [ {Filename} ] [ IOS ] [ FEATURE ] [ {Directory} ] |-----------| [ ANDROID ] [ MINOR ] [ {Framework} ] - | {Message} | [----------] [-------------] [-------------] |-----------| EXAMPLES: [IOS] [BREAKING] [FlatList] - Change a thing that breaks other things [ANDROID] [BUGFIX] [TextInput] - Did a thing to TextInput [CLI] [FEATURE] [local-cli/info/info.js] - CLI easier to do things with [DOCS] [BUGFIX] [GettingStarted.md] - Accidentally a thing/word [GENERAL] [ENHANCEMENT] [Yoga] - Added new yoga thing/position [INTERNAL] [FEATURE] [./scripts] - Added thing to script that nobody will see --> Pull Request resolved: https://github.com/facebook/react-native/pull/19809 Differential Revision: D13326614 Pulled By: shergin fbshipit-source-id: 6a5cab3f7290f0f623a6f4c29353a573eb321b0b
275 lines
8.3 KiB
Objective-C
275 lines
8.3 KiB
Objective-C
/**
|
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*/
|
|
|
|
#import "RCTUITextView.h"
|
|
|
|
#import <React/RCTUtils.h>
|
|
#import <React/UIView+React.h>
|
|
|
|
#import "RCTBackedTextInputDelegateAdapter.h"
|
|
|
|
@implementation RCTUITextView
|
|
{
|
|
UILabel *_placeholderView;
|
|
UITextView *_detachedTextView;
|
|
RCTBackedTextViewDelegateAdapter *_textInputDelegateAdapter;
|
|
}
|
|
|
|
static UIFont *defaultPlaceholderFont()
|
|
{
|
|
return [UIFont systemFontOfSize:17];
|
|
}
|
|
|
|
static UIColor *defaultPlaceholderColor()
|
|
{
|
|
// Default placeholder color from UITextField.
|
|
return [UIColor colorWithRed:0 green:0 blue:0.0980392 alpha:0.22];
|
|
}
|
|
|
|
- (instancetype)initWithFrame:(CGRect)frame
|
|
{
|
|
if (self = [super initWithFrame:frame]) {
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
selector:@selector(textDidChange)
|
|
name:UITextViewTextDidChangeNotification
|
|
object:self];
|
|
|
|
_placeholderView = [[UILabel alloc] initWithFrame:self.bounds];
|
|
_placeholderView.isAccessibilityElement = NO;
|
|
_placeholderView.numberOfLines = 0;
|
|
_placeholderView.textColor = defaultPlaceholderColor();
|
|
[self addSubview:_placeholderView];
|
|
|
|
_textInputDelegateAdapter = [[RCTBackedTextViewDelegateAdapter alloc] initWithTextView:self];
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
- (void)dealloc
|
|
{
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
}
|
|
|
|
- (NSString *)accessibilityLabel
|
|
{
|
|
NSMutableString *accessibilityLabel = [NSMutableString new];
|
|
|
|
NSString *superAccessibilityLabel = [super accessibilityLabel];
|
|
if (superAccessibilityLabel.length > 0) {
|
|
[accessibilityLabel appendString:superAccessibilityLabel];
|
|
}
|
|
|
|
if (self.placeholder.length > 0 && self.attributedText.string.length == 0) {
|
|
if (accessibilityLabel.length > 0) {
|
|
[accessibilityLabel appendString:@" "];
|
|
}
|
|
[accessibilityLabel appendString:self.placeholder];
|
|
}
|
|
|
|
return accessibilityLabel;
|
|
}
|
|
|
|
#pragma mark - Properties
|
|
|
|
- (void)setPlaceholder:(NSString *)placeholder
|
|
{
|
|
_placeholder = placeholder;
|
|
_placeholderView.text = _placeholder;
|
|
}
|
|
|
|
- (void)setPlaceholderColor:(UIColor *)placeholderColor
|
|
{
|
|
_placeholderColor = placeholderColor;
|
|
_placeholderView.textColor = _placeholderColor ?: defaultPlaceholderColor();
|
|
}
|
|
|
|
- (void)textDidChange
|
|
{
|
|
_textWasPasted = NO;
|
|
[self invalidatePlaceholderVisibility];
|
|
}
|
|
|
|
#pragma mark - Overrides
|
|
|
|
- (void)setFont:(UIFont *)font
|
|
{
|
|
[super setFont:font];
|
|
_placeholderView.font = font ?: defaultPlaceholderFont();
|
|
}
|
|
|
|
- (void)setTextAlignment:(NSTextAlignment)textAlignment
|
|
{
|
|
[super setTextAlignment:textAlignment];
|
|
_placeholderView.textAlignment = textAlignment;
|
|
}
|
|
|
|
- (void)setAttributedText:(NSAttributedString *)attributedText
|
|
{
|
|
// Using `setAttributedString:` while user is typing breaks some internal mechanics
|
|
// when entering complex input languages such as Chinese, Korean or Japanese.
|
|
// see: https://github.com/facebook/react-native/issues/19339
|
|
|
|
// We try to avoid calling this method as much as we can.
|
|
// If the text has changed, there is nothing we can do.
|
|
if (![super.attributedText.string isEqualToString:attributedText.string]) {
|
|
[super setAttributedText:attributedText];
|
|
} else {
|
|
// But if the text is preserved, we just copying the attributes from the source string.
|
|
if (![super.attributedText isEqualToAttributedString:attributedText]) {
|
|
[self copyTextAttributesFrom:attributedText];
|
|
}
|
|
}
|
|
|
|
[self textDidChange];
|
|
}
|
|
|
|
#pragma mark - Overrides
|
|
|
|
- (void)setSelectedTextRange:(UITextRange *)selectedTextRange notifyDelegate:(BOOL)notifyDelegate
|
|
{
|
|
if (!notifyDelegate) {
|
|
// We have to notify an adapter that following selection change was initiated programmatically,
|
|
// so the adapter must not generate a notification for it.
|
|
[_textInputDelegateAdapter skipNextTextInputDidChangeSelectionEventWithTextRange:selectedTextRange];
|
|
}
|
|
|
|
[super setSelectedTextRange:selectedTextRange];
|
|
}
|
|
|
|
- (void)paste:(id)sender
|
|
{
|
|
[super paste:sender];
|
|
_textWasPasted = YES;
|
|
}
|
|
|
|
- (void)setContentOffset:(CGPoint)contentOffset animated:(__unused BOOL)animated
|
|
{
|
|
// Turning off scroll animation.
|
|
// This fixes the problem also known as "flaky scrolling".
|
|
[super setContentOffset:contentOffset animated:NO];
|
|
}
|
|
|
|
#pragma mark - Layout
|
|
|
|
- (CGFloat)preferredMaxLayoutWidth
|
|
{
|
|
// Returning size DOES contain `textContainerInset` (aka `padding`).
|
|
return _preferredMaxLayoutWidth ?: self.placeholderSize.width;
|
|
}
|
|
|
|
- (CGSize)placeholderSize
|
|
{
|
|
UIEdgeInsets textContainerInset = self.textContainerInset;
|
|
NSString *placeholder = self.placeholder ?: @"";
|
|
CGSize placeholderSize = [placeholder sizeWithAttributes:@{NSFontAttributeName: self.font ?: defaultPlaceholderFont()}];
|
|
placeholderSize = CGSizeMake(RCTCeilPixelValue(placeholderSize.width), RCTCeilPixelValue(placeholderSize.height));
|
|
placeholderSize.width += textContainerInset.left + textContainerInset.right;
|
|
placeholderSize.height += textContainerInset.top + textContainerInset.bottom;
|
|
// Returning size DOES contain `textContainerInset` (aka `padding`; as `sizeThatFits:` does).
|
|
return placeholderSize;
|
|
}
|
|
|
|
- (CGSize)contentSize
|
|
{
|
|
CGSize contentSize = super.contentSize;
|
|
CGSize placeholderSize = self.placeholderSize;
|
|
// When a text input is empty, it actually displays a placehoder.
|
|
// So, we have to consider `placeholderSize` as a minimum `contentSize`.
|
|
// Returning size DOES contain `textContainerInset` (aka `padding`).
|
|
return CGSizeMake(
|
|
MAX(contentSize.width, placeholderSize.width),
|
|
MAX(contentSize.height, placeholderSize.height));
|
|
}
|
|
|
|
- (void)layoutSubviews
|
|
{
|
|
[super layoutSubviews];
|
|
|
|
CGRect textFrame = UIEdgeInsetsInsetRect(self.bounds, self.textContainerInset);
|
|
CGFloat placeholderHeight = [_placeholderView sizeThatFits:textFrame.size].height;
|
|
textFrame.size.height = MIN(placeholderHeight, textFrame.size.height);
|
|
_placeholderView.frame = textFrame;
|
|
}
|
|
|
|
- (CGSize)intrinsicContentSize
|
|
{
|
|
// Returning size DOES contain `textContainerInset` (aka `padding`).
|
|
return [self sizeThatFits:CGSizeMake(self.preferredMaxLayoutWidth, CGFLOAT_MAX)];
|
|
}
|
|
|
|
- (CGSize)sizeThatFits:(CGSize)size
|
|
{
|
|
// Returned fitting size depends on text size and placeholder size.
|
|
CGSize textSize = [self fixedSizeThatFits:size];
|
|
CGSize placeholderSize = self.placeholderSize;
|
|
// Returning size DOES contain `textContainerInset` (aka `padding`).
|
|
return CGSizeMake(MAX(textSize.width, placeholderSize.width), MAX(textSize.height, placeholderSize.height));
|
|
}
|
|
|
|
- (CGSize)fixedSizeThatFits:(CGSize)size
|
|
{
|
|
// UITextView on iOS 8 has a bug that automatically scrolls to the top
|
|
// when calling `sizeThatFits:`. Use a copy so that self is not screwed up.
|
|
static BOOL useCustomImplementation = NO;
|
|
static dispatch_once_t onceToken;
|
|
dispatch_once(&onceToken, ^{
|
|
useCustomImplementation = ![[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:(NSOperatingSystemVersion){9,0,0}];
|
|
});
|
|
|
|
if (!useCustomImplementation) {
|
|
return [super sizeThatFits:size];
|
|
}
|
|
|
|
if (!_detachedTextView) {
|
|
_detachedTextView = [UITextView new];
|
|
}
|
|
|
|
_detachedTextView.attributedText = self.attributedText;
|
|
_detachedTextView.font = self.font;
|
|
_detachedTextView.textContainerInset = self.textContainerInset;
|
|
|
|
return [_detachedTextView sizeThatFits:size];
|
|
}
|
|
|
|
#pragma mark - Context Menu
|
|
|
|
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
|
|
{
|
|
if (_contextMenuHidden) {
|
|
return NO;
|
|
}
|
|
|
|
return [super canPerformAction:action withSender:sender];
|
|
}
|
|
|
|
#pragma mark - Placeholder
|
|
|
|
- (void)invalidatePlaceholderVisibility
|
|
{
|
|
BOOL isVisible = _placeholder.length != 0 && self.attributedText.length == 0;
|
|
_placeholderView.hidden = !isVisible;
|
|
}
|
|
|
|
#pragma mark - Utility Methods
|
|
|
|
- (void)copyTextAttributesFrom:(NSAttributedString *)sourceString
|
|
{
|
|
[self.textStorage beginEditing];
|
|
|
|
NSTextStorage *textStorage = self.textStorage;
|
|
[sourceString enumerateAttributesInRange:NSMakeRange(0, sourceString.length)
|
|
options:NSAttributedStringEnumerationReverse
|
|
usingBlock:^(NSDictionary<NSAttributedStringKey,id> * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) {
|
|
[textStorage setAttributes:attrs range:range];
|
|
}];
|
|
|
|
[self.textStorage endEditing];
|
|
}
|
|
|
|
@end
|