From ef835b52e76af2ce7eebbdfcdeaebefb4ce44288 Mon Sep 17 00:00:00 2001 From: Stephen Cook Date: Mon, 13 Aug 2018 21:28:39 -0700 Subject: [PATCH] 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 --- Libraries/Text/TextStylePropTypes.js | 3 - RNTester/js/TextExample.android.js | 50 +++++++++++ .../views/text/CustomTextTransformSpan.java | 83 +++++++++++++++++++ .../views/text/ReactBaseTextShadowNode.java | 27 ++++++ .../react/views/text/TextTransform.java | 13 +++ .../react/views/text/ReactTextTest.java | 65 +++++++++++++++ 6 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/views/text/CustomTextTransformSpan.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/views/text/TextTransform.java diff --git a/Libraries/Text/TextStylePropTypes.js b/Libraries/Text/TextStylePropTypes.js index 28f63c01d7d3e6..90280aa5c5e24e 100644 --- a/Libraries/Text/TextStylePropTypes.js +++ b/Libraries/Text/TextStylePropTypes.js @@ -108,9 +108,6 @@ const TextStylePropTypes = { * @platform ios */ textDecorationColor: ColorPropType, - /** - * @platform ios - */ textTransform: ReactPropTypes.oneOf([ 'none' /*default*/, 'capitalize', diff --git a/RNTester/js/TextExample.android.js b/RNTester/js/TextExample.android.js index 724fb9d0659e83..482dd51e0541ce 100644 --- a/RNTester/js/TextExample.android.js +++ b/RNTester/js/TextExample.android.js @@ -535,6 +535,56 @@ class TextExample extends React.Component<{}> { make text look slightly misaligned when centered vertically. + + + This text should be uppercased. + + + This TEXT SHOULD be lowercased. + + + This text should be CAPITALIZED. + + + Mixed: uppercase + LoWeRcAsE + + capitalize each word + + + + Should be "ABC": + + abc + + + + Should be "AbC": + + abc + + + + { + '.aa\tbb\t\tcc dd EE \r\nZZ I like to eat apples. \n中文éé 我喜欢吃苹果。awdawd ' + } + + + { + '.aa\tbb\t\tcc dd EE \r\nZZ I like to eat apples. \n中文éé 我喜欢吃苹果。awdawd ' + } + + + { + '.aa\tbb\t\tcc dd EE \r\nZZ I like to eat apples. \n中文éé 我喜欢吃苹果。awdawd ' + } + + + { + '.aa\tbb\t\tcc dd EE \r\nZZ I like to eat apples. \n中文éé 我喜欢吃苹果。awdawd ' + } + + ); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomTextTransformSpan.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomTextTransformSpan.java new file mode 100644 index 00000000000000..d8adbb7d58a1a8 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomTextTransformSpan.java @@ -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(); + } + +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index 6ba9d435b0d1e2..fa59571a90ae20 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -53,6 +53,8 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode { public static final String PROP_SHADOW_RADIUS = "textShadowRadius"; 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; private static class SetSpanOperation { @@ -168,6 +170,13 @@ private static void buildSpannedFromShadowNode( new SetSpanOperation( 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()))); } } @@ -257,6 +266,7 @@ private static int parseNumericFontWeight(String fontWeightString) { protected int mTextAlign = Gravity.NO_GRAVITY; protected int mTextBreakStrategy = (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 mTextShadowOffsetDy = 0; @@ -313,6 +323,7 @@ public ReactBaseTextShadowNode(ReactBaseTextShadowNode node) { mLineHeightInput = node.mLineHeightInput; mTextAlign = node.mTextAlign; mTextBreakStrategy = node.mTextBreakStrategy; + mTextTransform = node.mTextTransform; mTextShadowOffsetDx = node.mTextShadowOffsetDx; mTextShadowOffsetDy = node.mTextShadowOffsetDy; @@ -567,4 +578,20 @@ public void setTextShadowColor(int textShadowColor) { 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(); + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextTransform.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextTransform.java new file mode 100644 index 00000000000000..36290d06a8cbe0 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextTransform.java @@ -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 }; diff --git a/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextTest.java b/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextTest.java index cb113637326ada..afe7b64b13bafc 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextTest.java @@ -36,6 +36,7 @@ import com.facebook.react.uimanager.ViewProps; import com.facebook.react.views.text.ReactRawTextShadowNode; import com.facebook.react.views.view.ReactViewBackgroundDrawable; +import com.facebook.react.views.text.CustomTextTransformSpan; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -341,6 +342,70 @@ public void testBackgroundColorStyleApplied() { 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 // only use TextView#setMaxLines() which exists since API Level 1. @TargetApi(Build.VERSION_CODES.JELLY_BEAN)