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] Implement letterSpacing option #13199

Closed
wants to merge 3 commits into from
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
11 changes: 10 additions & 1 deletion Libraries/Text/TextStylePropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,16 @@ const TextStylePropTypes = {
textShadowRadius: ReactPropTypes.number,
textShadowColor: ColorPropType,
/**
* @platform ios
* Increase or decrease the spacing between characters. Based on the platform specific
* rendering this style annotation will be rendered slightly differently on Android and iOS.
* Default is no letter spacing.
*
* Android: Only supported since Android 5+, older versions will will ignore this attribute.
* Please notice that additional space will be added *around* the characters and the space
* is calculated based on your font size and the font family. To left-align a text similar
* to iOS (or in different font sizes) you should add a Platform-specific negative layout attribute.
*
* iOS: The additional space will be added behind the character and is defined in points.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this implying that android and ios also use two different units? If so, can we make them use the same unit?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that are different units. The problem is that the Android only supports the M-unit while iOS only supports points. Is it not possible (for me) to use the exakt same unit here. 🤷‍♂️

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see two options then:

  1. convert em to pt: unfortunately this is going to require measuring the width of an M in the current font/font size (and also recalculating it when the font/font size changes), and it would probably work quite poorly for any animations that adjust font size (not that I know of any).

  2. Create two different props (letterSpacingIOS, letterSpacingAndroid).

I much prefer (1) but let me know what you think. I don't think having the same prop behave very differently on both platforms is a good solution.

Copy link
Contributor Author

@christoph-jerolimov christoph-jerolimov May 4, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like you, I would prefer option one.

Can you help me to calculating the exact width of the letter M in a performant way?

Did you see my current calculation in calculateLetterSpacing which is a "high performant" 😏 way which calculates a good result in 90%... 🤷‍♂️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, when defining 5 as letter spacing, this means 5 points on iOS.

With the current implementation this means "letterSpacingInput / fontSize * 2f" M on Android.

With a fontSize of 14 dpi this means ~0.71 M. And because the "small M" needs round about 8-10 dpi* from left to right this results in 5-6 dpis "real letter spacing" on Android.

With a fontSize of 28 dpi this means ~0.36 M. And because the "bigger M" here needs round about 16-20 dpi* from left to right this results in 6-7 dpis "real letter spacing" on Android.

In our case (and I think in many more cases) this is much better than no letter spacing. 🙈 Also if I know, an addtional PR can improve this in the future. And I'm looking forward to anybody who can and will do this. S/he will get some free 🍻 from us, here in Cologne. 😄

*I does not measure the size exactly because it depends on the selected font family.

Copy link
Contributor

@astreet astreet May 5, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'm fine siding with practicality here then. If you wanted to measure an 'M', you'd want to look at how we do it in our text shadow node: basically use a BoringLayout. But I'm also fine if you come up with some formula that at least factors in font size (looks like you already have something like that -- if so, add some more detailed docs for the prop about how we're emulating what iOS does, but it isn't perfect)

*/
letterSpacing: ReactPropTypes.number,
lineHeight: ReactPropTypes.number,
Expand Down
16 changes: 16 additions & 0 deletions RNTester/js/TextExample.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,22 @@ class TextExample extends React.Component<{}> {
Holisticly formulate inexpensive ideas before best-of-breed benefits. <Text style={{fontSize: 20}}>Continually</Text> expedite magnetic potentialities rather than client-focused interfaces.
</Text>
</RNTesterBlock>
<UIExplorerBlock title="Letter Spacing">
<View>
<Text style={{letterSpacing: 0}}>
letterSpacing = 0
</Text>
<Text style={{letterSpacing: 2, marginTop: 5}}>
letterSpacing = 2
</Text>
<Text style={{letterSpacing: 9, marginTop: 5}}>
letterSpacing = 9
</Text>
<Text style={{letterSpacing: -1, marginTop: 5}}>
letterSpacing = -1
</Text>
</View>
</UIExplorerBlock>
<RNTesterBlock title="Empty Text">
<Text />
</RNTesterBlock>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public class ViewProps {
public static final String FONT_STYLE = "fontStyle";
public static final String FONT_FAMILY = "fontFamily";
public static final String LINE_HEIGHT = "lineHeight";
public static final String LETTER_SPACING = "letterSpacing";
public static final String NEEDS_OFFSCREEN_ALPHA_COMPOSITING = "needsOffscreenAlphaCompositing";
public static final String NUMBER_OF_LINES = "numberOfLines";
public static final String ELLIPSIZE_MODE = "ellipsizeMode";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/

package com.facebook.react.views.text;

import android.annotation.TargetApi;
import android.os.Build;
import android.text.TextPaint;
import android.text.style.MetricAffectingSpan;

import com.facebook.infer.annotation.Assertions;

/**
* A {@link MetricAffectingSpan} that allows to set the letter spacing
* on the selected text span.
*/
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public class CustomLetterSpacingSpan extends MetricAffectingSpan {

private final float mLetterSpacing;

public CustomLetterSpacingSpan(float letterSpacing) {
Assertions.assertCondition(!Float.isNaN(letterSpacing) && letterSpacing != 0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use SoftAssertions so that we throw a catchable Exception and not an Error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please note that this happen only if you change the internal code. This can not happen by any input from JSX. But I can change this when nothing other blocks this PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still would like us to not use Errors when we can use Exceptions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Will change it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having less points to discuss is the fastest way to get PR approved. 😄

mLetterSpacing = letterSpacing;
}

@Override
public void updateDrawState(TextPaint paint) {
paint.setLetterSpacing(mLetterSpacing);
}

@Override
public void updateMeasureState(TextPaint paint) {
paint.setLetterSpacing(mLetterSpacing);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,14 @@ private static void buildSpannedFromTextCSSNode(
end,
new CustomLineHeightSpan(textShadowNode.getEffectiveLineHeight())));
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (textShadowNode.mLetterSpacing != 0) {
ops.add(new SetSpanOperation(
start,
end,
new CustomLetterSpacingSpan(textShadowNode.mLetterSpacing)));
}
}
ops.add(new SetSpanOperation(start, end, new ReactTagSpan(textShadowNode.getReactTag())));
}
}
Expand Down Expand Up @@ -329,7 +337,26 @@ private static int parseNumericFontWeight(String fontWeightString) {
100 * (fontWeightString.charAt(0) - '0') : -1;
}

/**
* {@link Android TextView#setLetterSpacing} expects the letter spacing value
* in 'EM' unit, where one is defined by the width of the letter 'M'.
* Typical values for slight expansion will be around 0.05.
* Negative values will tighten text. So we use NaN as not defined value.
*
* This method calculates an 'EM' <strong>approach</strong> based on the input
* and the calculated font size. With common fonts this calculated value
* renders a similar spacing and layout to iOS.
*/
private static float calculateLetterSpacing(float letterSpacingInput, int fontSize) {
if (Float.isNaN(letterSpacingInput) || letterSpacingInput == 0) {
return 0;
} else {
return letterSpacingInput / fontSize * 2f;
}
}

private float mLineHeight = Float.NaN;
protected float mLetterSpacing = 0;
private boolean mIsColorSet = false;
private boolean mAllowFontScaling = true;
private int mColor;
Expand All @@ -340,6 +367,7 @@ private static int parseNumericFontWeight(String fontWeightString) {
protected int mFontSize = UNSET;
protected float mFontSizeInput = UNSET;
protected float mLineHeightInput = UNSET;
protected float mLetterSpacingInput = Float.NaN;
protected int mTextAlign = Gravity.NO_GRAVITY;
protected int mTextBreakStrategy = (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) ?
0 : Layout.BREAK_STRATEGY_HIGH_QUALITY;
Expand Down Expand Up @@ -454,6 +482,13 @@ public void setLineHeight(float lineHeight) {
markUpdated();
}

@ReactProp(name = ViewProps.LETTER_SPACING, defaultFloat = Float.NaN)
public void setLetterSpacing(float letterSpacing) {
mLetterSpacingInput = letterSpacing;
mLetterSpacing = calculateLetterSpacing(letterSpacing, mFontSize);
markUpdated();
}

@ReactProp(name = ViewProps.ALLOW_FONT_SCALING, defaultBoolean = true)
public void setAllowFontScaling(boolean allowFontScaling) {
if (allowFontScaling != mAllowFontScaling) {
Expand Down Expand Up @@ -491,6 +526,7 @@ public void setFontSize(float fontSize) {
: (float) Math.ceil(PixelUtil.toPixelFromDIP(fontSize));
}
mFontSize = (int) fontSize;
mLetterSpacing = calculateLetterSpacing(mLetterSpacingInput, mFontSize);
markUpdated();
}

Expand Down Expand Up @@ -658,6 +694,7 @@ public void onCollectExtraUpdates(UIViewOperationQueue uiViewOperationQueue) {
getPadding(Spacing.TOP),
getPadding(Spacing.END),
getPadding(Spacing.BOTTOM),
mLetterSpacing,
getTextAlign(),
mTextBreakStrategy
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public class ReactTextUpdate {
private final float mPaddingBottom;
private final int mTextAlign;
private final int mTextBreakStrategy;
private final float mLetterSpacing;

/**
* @deprecated Use a non-deprecated constructor for ReactTextUpdate instead. This one remains
Expand All @@ -50,6 +51,7 @@ public ReactTextUpdate(
paddingTop,
paddingEnd,
paddingBottom,
0f,
textAlign,
Layout.BREAK_STRATEGY_HIGH_QUALITY);
}
Expand All @@ -62,6 +64,7 @@ public ReactTextUpdate(
float paddingTop,
float paddingEnd,
float paddingBottom,
float letterSpacing,
int textAlign,
int textBreakStrategy) {
mText = text;
Expand All @@ -71,6 +74,7 @@ public ReactTextUpdate(
mPaddingTop = paddingTop;
mPaddingRight = paddingEnd;
mPaddingBottom = paddingBottom;
mLetterSpacing = letterSpacing;
mTextAlign = textAlign;
mTextBreakStrategy = textBreakStrategy;
}
Expand Down Expand Up @@ -103,6 +107,10 @@ public float getPaddingBottom() {
return mPaddingBottom;
}

public float getLetterSpacing() {
return mLetterSpacing;
}

public int getTextAlign() {
return mTextAlign;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ public void setText(ReactTextUpdate update) {
(int) Math.floor(update.getPaddingRight()),
(int) Math.floor(update.getPaddingBottom()));

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
float nextLetterSpacing = update.getLetterSpacing();
if (Float.isNaN(nextLetterSpacing)) {
nextLetterSpacing = 0;
}
if (getLetterSpacing() != nextLetterSpacing) {
setLetterSpacing(nextLetterSpacing);
}
}

int nextTextAlign = update.getTextAlign();
if (mTextAlign != nextTextAlign) {
mTextAlign = nextTextAlign;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ public void onCollectExtraUpdates(UIViewOperationQueue uiViewOperationQueue) {
getPadding(Spacing.TOP),
getPadding(Spacing.RIGHT),
getPadding(Spacing.BOTTOM),
mLetterSpacing,
mTextAlign,
mTextBreakStrategy
);
Expand Down