Skip to content
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

[Android] Fix issue#11068 of duplicating characters when replacing letters to lowercase or uppercase in TextInput (Adjusted) #38649

Closed
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 @@ -166,6 +166,9 @@ public class ReactFeatureFlags {
/** Enables Stable API for TurboModule (removal of ReactModule, ReactModuleInfoProvider). */
public static boolean enableTurboModuleStableAPI = false;

/** Enable keeping Composing Spans on Text input change if the new text has the same length. */
public static boolean enableComposingSpanRestorationOnSameLength = true;

/**
* When enabled, it uses the modern fork of RuntimeScheduler that allows scheduling tasks with
* priorities from any thread.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
package com.facebook.react.views.textinput;

import static com.facebook.react.uimanager.UIManagerHelper.getReactContext;
import static com.facebook.react.config.ReactFeatureFlags.enableComposingSpanRestorationOnSameLength;

import android.content.Context;
import android.graphics.Color;
Expand Down Expand Up @@ -47,6 +48,7 @@
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactSoftExceptionLogger;
import com.facebook.react.common.build.ReactBuildConfig;
import com.facebook.react.config.ReactFeatureFlags;
import com.facebook.react.uimanager.ReactAccessibilityDelegate;
import com.facebook.react.uimanager.StateWrapper;
import com.facebook.react.uimanager.UIManagerModule;
Expand Down Expand Up @@ -676,6 +678,8 @@ public void maybeSetText(ReactTextUpdate reactTextUpdate) {
// try to update state if the wrapper is available. Temporarily disable
// to prevent an infinite loop.
getText().replace(0, length(), spannableStringBuilder);

attachCompositeSpansToTextFrom(spannableStringBuilder);
}
mDisableTextDiffing = false;

Expand All @@ -688,13 +692,19 @@ public void maybeSetText(ReactTextUpdate reactTextUpdate) {
}

/**
* Remove and/or add {@link Spanned.SPAN_EXCLUSIVE_EXCLUSIVE} spans, since they should only exist
* as long as the text they cover is the same. All other spans will remain the same, since they
* will adapt to the new text, hence why {@link SpannableStringBuilder#replace} never removes
* Remove and/or add {@link Spanned#SPAN_EXCLUSIVE_EXCLUSIVE} spans, since they should only exist
* as long as the text they cover is the same unless they are {@link Spanned#SPAN_COMPOSING}.
* All other spans will remain the same, since they will adapt to the new text, hence why {@link SpannableStringBuilder#replace} never removes
* them.
* When {@link ReactFeatureFlags#enableComposingSpanRestorationOnSameLength} is enabled,
* keep copy of {@link Spanned#SPAN_COMPOSING} Spans in {@param spannableStringBuilder}, because they are important for
* keyboard suggestions. Without keeping these Spans, suggestions default to be put after the current selection position,
* possibly resulting in letter duplication (ex. Samsung Keyboard).
*/
private void manageSpans(SpannableStringBuilder spannableStringBuilder) {
Object[] spans = getText().getSpans(0, length(), Object.class);
boolean shouldKeepComposingSpans = enableComposingSpanRestorationOnSameLength
&& length() == spannableStringBuilder.length();
for (int spanIdx = 0; spanIdx < spans.length; spanIdx++) {
Object span = spans[spanIdx];
int spanFlags = getText().getSpanFlags(span);
Expand All @@ -714,6 +724,15 @@ private void manageSpans(SpannableStringBuilder spannableStringBuilder) {
final int spanStart = getText().getSpanStart(span);
final int spanEnd = getText().getSpanEnd(span);

if (shouldKeepComposingSpans) {
// We keep a copy of Composing spans
boolean isComposing = (spanFlags & Spanned.SPAN_COMPOSING) == Spanned.SPAN_COMPOSING;
if (isComposing) {
spannableStringBuilder.setSpan(span, spanStart, spanEnd, spanFlags);
continue;
}
}

// Make sure the span is removed from existing text, otherwise the spans we set will be
// ignored or it will cover text that has changed.
getText().removeSpan(span);
Expand Down Expand Up @@ -841,6 +860,39 @@ private void addSpansFromStyleAttributes(SpannableStringBuilder workingText) {
}
}

/**
* When {@link ReactFeatureFlags#enableComposingSpanRestorationOnSameLength} is enabled, this
* function attaches the {@link Spanned#SPAN_COMPOSING} from {@param spannableStringBuilder} to
* {@link ReactEditText#getText} if they are the same length.
*
* See {@link ReactEditText#manageSpans} for more details.
* Also this <a href="https://github.com/facebook/react-native/issues/11068">GitHub issue</a>
*/
private void attachCompositeSpansToTextFrom(SpannableStringBuilder spannableStringBuilder) {
if (!enableComposingSpanRestorationOnSameLength) {
return;
}

Editable text = getText();
if (text == null || text.length() != spannableStringBuilder.length()) {
return;
}
Object[] spans = spannableStringBuilder.getSpans(0, length(), Object.class);
for (Object span : spans) {
int spanFlags = spannableStringBuilder.getSpanFlags(span);
boolean isComposing = (spanFlags & Spanned.SPAN_COMPOSING) == Spanned.SPAN_COMPOSING;

if (!isComposing) {
continue;
}

final int spanStart = spannableStringBuilder.getSpanStart(span);
final int spanEnd = spannableStringBuilder.getSpanEnd(span);

text.setSpan(span, spanStart, spanEnd, spanFlags);
}
}

private static boolean sameTextForSpan(
final Editable oldText,
final SpannableStringBuilder newText,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,30 @@ class RewriteExample extends React.Component<$FlowFixMeProps, any> {
}
}

class RewriteToLowerCaseExample extends React.Component<$FlowFixMeProps, any> {
constructor(props: any | void) {
super(props);
this.state = {text: ''};
}
render(): React.Node {
return (
<View style={styles.rewriteContainer}>
<TextInput
testID="rewrite_to_lowercase_input"
autoCorrect={false}
multiline={false}
onChangeText={text => {
text = text.toLowerCase();
this.setState({text});
}}
style={styles.default}
value={this.state.text}
/>
</View>
);
}
}

class RewriteExampleInvalidCharacters extends React.Component<
$FlowFixMeProps,
any,
Expand Down Expand Up @@ -854,6 +878,13 @@ module.exports = ([
return <RewriteExample />;
},
},
{
name: 'lowerCase',
title: 'Live Re-Write to LowerCase',
render: function (): React.Node {
return <RewriteToLowerCaseExample />;
},
},
{
title: 'Live Re-Write (no spaces allowed)',
render: function (): React.Node {
Expand Down