Android textTransform style support (#20572)
Summary: Issue https://github.com/facebook/react-native/issues/2088 (closed, but a bit pre-emptively imo, since Android support was skipped) Related (merged) iOS PR https://github.com/facebook/react-native/pull/18387 Related documentation PR https://github.com/facebook/react-native-website/pull/500 The basic desire is to have a declarative mechanism to transform text content to uppercase or lowercase or titlecase ("capitalized"). Pull Request resolved: https://github.com/facebook/react-native/pull/20572 Differential Revision: D9311716 Pulled By: hramos fbshipit-source-id: dfbb855117196958e7ae5e980700d31be07a448d
This commit is contained in:
parent
1081560d86
commit
22cf5dc566
|
@ -108,9 +108,6 @@ const TextStylePropTypes = {
|
||||||
* @platform ios
|
* @platform ios
|
||||||
*/
|
*/
|
||||||
textDecorationColor: ColorPropType,
|
textDecorationColor: ColorPropType,
|
||||||
/**
|
|
||||||
* @platform ios
|
|
||||||
*/
|
|
||||||
textTransform: ReactPropTypes.oneOf([
|
textTransform: ReactPropTypes.oneOf([
|
||||||
'none' /*default*/,
|
'none' /*default*/,
|
||||||
'capitalize',
|
'capitalize',
|
||||||
|
|
|
@ -531,6 +531,56 @@ class TextExample extends React.Component<{}> {
|
||||||
make text look slightly misaligned when centered vertically.
|
make text look slightly misaligned when centered vertically.
|
||||||
</Text>
|
</Text>
|
||||||
</RNTesterBlock>
|
</RNTesterBlock>
|
||||||
|
<RNTesterBlock title="Text transform">
|
||||||
|
<Text style={{textTransform: 'uppercase'}}>
|
||||||
|
This text should be uppercased.
|
||||||
|
</Text>
|
||||||
|
<Text style={{textTransform: 'lowercase'}}>
|
||||||
|
This TEXT SHOULD be lowercased.
|
||||||
|
</Text>
|
||||||
|
<Text style={{textTransform: 'capitalize'}}>
|
||||||
|
This text should be CAPITALIZED.
|
||||||
|
</Text>
|
||||||
|
<Text style={{textTransform: 'capitalize'}}>
|
||||||
|
Mixed: <Text style={{textTransform: 'uppercase'}}>uppercase </Text>
|
||||||
|
<Text style={{textTransform: 'lowercase'}}>LoWeRcAsE </Text>
|
||||||
|
<Text style={{textTransform: 'capitalize'}}>
|
||||||
|
capitalize each word
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
Should be "ABC":
|
||||||
|
<Text style={{textTransform: 'uppercase'}}>
|
||||||
|
a<Text>b</Text>c
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
Should be "AbC":
|
||||||
|
<Text style={{textTransform: 'uppercase'}}>
|
||||||
|
a<Text style={{textTransform: 'none'}}>b</Text>c
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={{textTransform: 'none'}}>
|
||||||
|
{
|
||||||
|
'.aa\tbb\t\tcc dd EE \r\nZZ I like to eat apples. \n中文éé 我喜欢吃苹果。awdawd '
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
<Text style={{textTransform: 'uppercase'}}>
|
||||||
|
{
|
||||||
|
'.aa\tbb\t\tcc dd EE \r\nZZ I like to eat apples. \n中文éé 我喜欢吃苹果。awdawd '
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
<Text style={{textTransform: 'lowercase'}}>
|
||||||
|
{
|
||||||
|
'.aa\tbb\t\tcc dd EE \r\nZZ I like to eat apples. \n中文éé 我喜欢吃苹果。awdawd '
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
<Text style={{textTransform: 'capitalize'}}>
|
||||||
|
{
|
||||||
|
'.aa\tbb\t\tcc dd EE \r\nZZ I like to eat apples. \n中文éé 我喜欢吃苹果。awdawd '
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
</RNTesterBlock>
|
||||||
</RNTesterPage>
|
</RNTesterPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2015-present, Facebook, Inc.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.facebook.react.views.text;
|
||||||
|
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.text.style.ReplacementSpan;
|
||||||
|
import java.text.BreakIterator;
|
||||||
|
|
||||||
|
public class CustomTextTransformSpan extends ReplacementSpan {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link ReplacementSpan} that allows declarative changing of text casing.
|
||||||
|
* CustomTextTransformSpan will change e.g. "foo" to "FOO", when passed UPPERCASE.
|
||||||
|
*
|
||||||
|
* This needs to be a Span in order to achieve correctly nested transforms
|
||||||
|
* (for Text nodes within Text nodes, each with separate needed transforms)
|
||||||
|
*/
|
||||||
|
|
||||||
|
private final TextTransform mTransform;
|
||||||
|
|
||||||
|
public CustomTextTransformSpan(TextTransform transform) {
|
||||||
|
mTransform = transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
|
||||||
|
CharSequence transformedText = transformText(text);
|
||||||
|
canvas.drawText(transformedText, start, end, x, y, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
|
||||||
|
CharSequence transformedText = transformText(text);
|
||||||
|
return Math.round(paint.measureText(transformedText, start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CharSequence transformText(CharSequence text) {
|
||||||
|
CharSequence transformed;
|
||||||
|
|
||||||
|
switch(mTransform) {
|
||||||
|
case UPPERCASE:
|
||||||
|
transformed = (CharSequence) text.toString().toUpperCase();
|
||||||
|
break;
|
||||||
|
case LOWERCASE:
|
||||||
|
transformed = (CharSequence) text.toString().toLowerCase();
|
||||||
|
break;
|
||||||
|
case CAPITALIZE:
|
||||||
|
transformed = (CharSequence) capitalize(text.toString());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
transformed = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String capitalize(String text) {
|
||||||
|
BreakIterator wordIterator = BreakIterator.getWordInstance();
|
||||||
|
wordIterator.setText(text);
|
||||||
|
|
||||||
|
StringBuilder res = new StringBuilder(text.length());
|
||||||
|
int start = wordIterator.first();
|
||||||
|
for (int end = wordIterator.next(); end != BreakIterator.DONE; end = wordIterator.next()) {
|
||||||
|
String word = text.substring(start, end);
|
||||||
|
if (Character.isLetterOrDigit(word.charAt(0))) {
|
||||||
|
res.append(Character.toUpperCase(word.charAt(0)));
|
||||||
|
res.append(word.substring(1).toLowerCase());
|
||||||
|
} else {
|
||||||
|
res.append(word);
|
||||||
|
}
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -52,6 +52,8 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
|
||||||
public static final String PROP_SHADOW_RADIUS = "textShadowRadius";
|
public static final String PROP_SHADOW_RADIUS = "textShadowRadius";
|
||||||
public static final String PROP_SHADOW_COLOR = "textShadowColor";
|
public static final String PROP_SHADOW_COLOR = "textShadowColor";
|
||||||
|
|
||||||
|
public static final String PROP_TEXT_TRANSFORM = "textTransform";
|
||||||
|
|
||||||
public static final int DEFAULT_TEXT_SHADOW_COLOR = 0x55000000;
|
public static final int DEFAULT_TEXT_SHADOW_COLOR = 0x55000000;
|
||||||
|
|
||||||
private static class SetSpanOperation {
|
private static class SetSpanOperation {
|
||||||
|
@ -164,6 +166,13 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
|
||||||
new SetSpanOperation(
|
new SetSpanOperation(
|
||||||
start, end, new CustomLineHeightSpan(textShadowNode.getEffectiveLineHeight())));
|
start, end, new CustomLineHeightSpan(textShadowNode.getEffectiveLineHeight())));
|
||||||
}
|
}
|
||||||
|
if (textShadowNode.mTextTransform != TextTransform.UNSET) {
|
||||||
|
ops.add(
|
||||||
|
new SetSpanOperation(
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
new CustomTextTransformSpan(textShadowNode.mTextTransform)));
|
||||||
|
}
|
||||||
ops.add(new SetSpanOperation(start, end, new ReactTagSpan(textShadowNode.getReactTag())));
|
ops.add(new SetSpanOperation(start, end, new ReactTagSpan(textShadowNode.getReactTag())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -251,6 +260,7 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
|
||||||
protected int mTextAlign = Gravity.NO_GRAVITY;
|
protected int mTextAlign = Gravity.NO_GRAVITY;
|
||||||
protected int mTextBreakStrategy =
|
protected int mTextBreakStrategy =
|
||||||
(Build.VERSION.SDK_INT < Build.VERSION_CODES.M) ? 0 : Layout.BREAK_STRATEGY_HIGH_QUALITY;
|
(Build.VERSION.SDK_INT < Build.VERSION_CODES.M) ? 0 : Layout.BREAK_STRATEGY_HIGH_QUALITY;
|
||||||
|
protected TextTransform mTextTransform = TextTransform.UNSET;
|
||||||
|
|
||||||
protected float mTextShadowOffsetDx = 0;
|
protected float mTextShadowOffsetDx = 0;
|
||||||
protected float mTextShadowOffsetDy = 0;
|
protected float mTextShadowOffsetDy = 0;
|
||||||
|
@ -307,6 +317,7 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
|
||||||
mLineHeightInput = node.mLineHeightInput;
|
mLineHeightInput = node.mLineHeightInput;
|
||||||
mTextAlign = node.mTextAlign;
|
mTextAlign = node.mTextAlign;
|
||||||
mTextBreakStrategy = node.mTextBreakStrategy;
|
mTextBreakStrategy = node.mTextBreakStrategy;
|
||||||
|
mTextTransform = node.mTextTransform;
|
||||||
|
|
||||||
mTextShadowOffsetDx = node.mTextShadowOffsetDx;
|
mTextShadowOffsetDx = node.mTextShadowOffsetDx;
|
||||||
mTextShadowOffsetDy = node.mTextShadowOffsetDy;
|
mTextShadowOffsetDy = node.mTextShadowOffsetDy;
|
||||||
|
@ -561,4 +572,20 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode {
|
||||||
markUpdated();
|
markUpdated();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ReactProp(name = PROP_TEXT_TRANSFORM)
|
||||||
|
public void setTextTransform(@Nullable String textTransform) {
|
||||||
|
if (textTransform == null || "none".equals(textTransform)) {
|
||||||
|
mTextTransform = TextTransform.NONE;
|
||||||
|
} else if ("uppercase".equals(textTransform)) {
|
||||||
|
mTextTransform = TextTransform.UPPERCASE;
|
||||||
|
} else if ("lowercase".equals(textTransform)) {
|
||||||
|
mTextTransform = TextTransform.LOWERCASE;
|
||||||
|
} else if ("capitalize".equals(textTransform)) {
|
||||||
|
mTextTransform = TextTransform.CAPITALIZE;
|
||||||
|
} else {
|
||||||
|
throw new JSApplicationIllegalArgumentException("Invalid textTransform: " + textTransform);
|
||||||
|
}
|
||||||
|
markUpdated();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2015-present, Facebook, Inc.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.facebook.react.views.text;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types of text transforms for CustomTextTransformSpan
|
||||||
|
*/
|
||||||
|
public enum TextTransform { NONE, UPPERCASE, LOWERCASE, CAPITALIZE, UNSET };
|
|
@ -36,6 +36,7 @@ import com.facebook.react.uimanager.ViewManager;
|
||||||
import com.facebook.react.uimanager.ViewProps;
|
import com.facebook.react.uimanager.ViewProps;
|
||||||
import com.facebook.react.views.text.ReactRawTextShadowNode;
|
import com.facebook.react.views.text.ReactRawTextShadowNode;
|
||||||
import com.facebook.react.views.view.ReactViewBackgroundDrawable;
|
import com.facebook.react.views.view.ReactViewBackgroundDrawable;
|
||||||
|
import com.facebook.react.views.text.CustomTextTransformSpan;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -341,6 +342,70 @@ public class ReactTextTest {
|
||||||
assertThat(((ReactViewBackgroundDrawable) backgroundDrawable).getColor()).isEqualTo(Color.BLUE);
|
assertThat(((ReactViewBackgroundDrawable) backgroundDrawable).getColor()).isEqualTo(Color.BLUE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTextTransformNoneApplied() {
|
||||||
|
UIManagerModule uiManager = getUIManagerModule();
|
||||||
|
|
||||||
|
String testTextEntered = ".aa\tbb\t\tcc dd EE \r\nZZ I like to eat apples. \n中文éé 我喜欢吃苹果。awdawd ";
|
||||||
|
String testTextTransformed = testTextEntered;
|
||||||
|
|
||||||
|
ReactRootView rootView = createText(
|
||||||
|
uiManager,
|
||||||
|
JavaOnlyMap.of("textTransform", "none"),
|
||||||
|
JavaOnlyMap.of(ReactRawTextShadowNode.PROP_TEXT, testTextEntered));
|
||||||
|
|
||||||
|
TextView textView = (TextView) rootView.getChildAt(0);
|
||||||
|
assertThat(textView.getText().toString()).isEqualTo(testTextTransformed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTextTransformUppercaseApplied() {
|
||||||
|
UIManagerModule uiManager = getUIManagerModule();
|
||||||
|
|
||||||
|
String testTextEntered = ".aa\tbb\t\tcc dd EE \r\nZZ I like to eat apples. \n中文éé 我喜欢吃苹果。awdawd ";
|
||||||
|
String testTextTransformed = ".AA\tBB\t\tCC DD EE \r\nZZ I LIKE TO EAT APPLES. \n中文ÉÉ 我喜欢吃苹果。AWDAWD ";
|
||||||
|
|
||||||
|
ReactRootView rootView = createText(
|
||||||
|
uiManager,
|
||||||
|
JavaOnlyMap.of("textTransform", "uppercase"),
|
||||||
|
JavaOnlyMap.of(ReactRawTextShadowNode.PROP_TEXT, testTextEntered));
|
||||||
|
|
||||||
|
TextView textView = (TextView) rootView.getChildAt(0);
|
||||||
|
assertThat(textView.getText().toString()).isEqualTo(testTextTransformed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTextTransformLowercaseApplied() {
|
||||||
|
UIManagerModule uiManager = getUIManagerModule();
|
||||||
|
|
||||||
|
String testTextEntered = ".aa\tbb\t\tcc dd EE \r\nZZ I like to eat apples. \n中文éé 我喜欢吃苹果。awdawd ";
|
||||||
|
String testTextTransformed = ".aa\tbb\t\tcc dd ee \r\nzz i like to eat apples. \n中文éé 我喜欢吃苹果。awdawd ";
|
||||||
|
|
||||||
|
ReactRootView rootView = createText(
|
||||||
|
uiManager,
|
||||||
|
JavaOnlyMap.of("textTransform", "lowercase"),
|
||||||
|
JavaOnlyMap.of(ReactRawTextShadowNode.PROP_TEXT, testTextEntered));
|
||||||
|
|
||||||
|
TextView textView = (TextView) rootView.getChildAt(0);
|
||||||
|
assertThat(textView.getText().toString()).isEqualTo(testTextTransformed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testTextTransformCapitalizeApplied() {
|
||||||
|
UIManagerModule uiManager = getUIManagerModule();
|
||||||
|
|
||||||
|
String testTextEntered = ".aa\tbb\t\tcc dd EE \r\nZZ I like to eat apples. \n中文éé 我喜欢吃苹果。awdawd ";
|
||||||
|
String testTextTransformed = ".Aa\tBb\t\tCc Dd Ee \r\nZz I Like To Eat Apples. \n中文Éé 我喜欢吃苹果。Awdawd ";
|
||||||
|
|
||||||
|
ReactRootView rootView = createText(
|
||||||
|
uiManager,
|
||||||
|
JavaOnlyMap.of("textTransform", "capitalize"),
|
||||||
|
JavaOnlyMap.of(ReactRawTextShadowNode.PROP_TEXT, testTextEntered));
|
||||||
|
|
||||||
|
TextView textView = (TextView) rootView.getChildAt(0);
|
||||||
|
assertThat(textView.getText().toString()).isEqualTo(testTextTransformed);
|
||||||
|
}
|
||||||
|
|
||||||
// JELLY_BEAN is needed for TextView#getMaxLines(), which is OK, because in the actual code we
|
// JELLY_BEAN is needed for TextView#getMaxLines(), which is OK, because in the actual code we
|
||||||
// only use TextView#setMaxLines() which exists since API Level 1.
|
// only use TextView#setMaxLines() which exists since API Level 1.
|
||||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
|
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
|
||||||
|
|
Loading…
Reference in New Issue