Skip to content
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 @@ -712,7 +712,8 @@ public PreparedLayout reusePreparedLayoutWithNewReactTags(
preparedLayout.getVerticalOffset(),
reactTags,
preparedLayout.getTextBreakStrategy(),
preparedLayout.getJustificationMode());
preparedLayout.getJustificationMode(),
preparedLayout.getBaseTextAttributes());
}

@AnyThread
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ internal class PreparedLayout(
val reactTags: IntArray,
val textBreakStrategy: Int,
val justificationMode: Int,
val baseTextAttributes: TextAttributeProps,
)
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
reactTags,
textBreakStrategy,
justificationMode,
baseTextAttributes,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal class ReactTextUpdate(
val textAlign: Int,
val textBreakStrategy: Int,
val justificationMode: Int,
val baseTextAttributes: TextAttributeProps? = null,
) {
companion object {
@JvmStatic
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie
// compile against an SDK older than 35 (see AndroidVersion).
private static final @Nullable Method SET_USE_BOUNDS_FOR_WIDTH = resolveSetUseBoundsForWidth();

private final float mDefaultTextSize;
private int mNumberOfLines;
private @Nullable TextUtils.TruncateAt mEllipsizeLocation;
private boolean mAdjustsFontSizeToFit;
Expand All @@ -85,6 +86,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie
@SuppressWarnings("this-escape")
public ReactTextView(Context context) {
super(context);
mDefaultTextSize = getTextSize();
initView();
}

Expand Down Expand Up @@ -165,6 +167,7 @@ private void initView() {
// reset text
setLayoutParams(EMPTY_LAYOUT_PARAMS);
super.setText(null);
TextLayoutManager.resetTextPaint(getPaint(), mDefaultTextSize);
applyTextAttributes();

// Call setters to ensure that any super setters are called
Expand Down Expand Up @@ -384,6 +387,7 @@ public void setText(ReactTextUpdate update) {
Linkify.addLinks((Spannable) spanned, mLinkifyMaskType);
setMovementMethod(LinkMovementMethod.getInstance());
}
applyBaseTextAttributes(update.getBaseTextAttributes());
setText(spanned);

int nextTextAlign = update.getTextAlign();
Expand All @@ -404,6 +408,24 @@ public void setText(ReactTextUpdate update) {
}
}

/**
* Android draws generated ellipses with the view's paint rather than a span, so it must match the
* base attributes TextLayoutManager measured with.
*/
private void applyBaseTextAttributes(@Nullable TextAttributeProps baseTextAttributes) {
if (baseTextAttributes == null) {
return;
}

TextLayoutManager.resetTextPaint(getPaint(), mDefaultTextSize);
applyTextAttributes();
TextLayoutManager.updateTextPaint(
getPaint(),
baseTextAttributes,
getContext().getAssets(),
ReactTypefaceUtils.getFontWeightAdjustment(getContext()));
}

@Override
public int reactTagForTouch(float touchX, float touchY) {
CharSequence text = getText();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ public constructor(
TextLayoutManager.getTextGravity(attributedString, spanned),
textBreakStrategy,
TextAttributeProps.getJustificationMode(props, currentJustificationMode),
TextAttributeProps.fromMapBuffer(
attributedString.getMapBuffer(TextLayoutManager.AS_KEY_BASE_ATTRIBUTES),
),
)
}

Expand Down Expand Up @@ -230,6 +233,7 @@ public constructor(
textAlign,
preparedLayout.textBreakStrategy,
preparedLayout.justificationMode,
preparedLayout.baseTextAttributes,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ public class TextAttributeProps private constructor() {
private var lineHeightInput: Float = ReactConstants.UNSET.toFloat()
private var letterSpacingInput: Float = Float.NaN

internal val isLetterSpacingSet: Boolean
get() = !letterSpacingInput.isNaN()

// `ReactConstants.UNSET` is -1, same as `LayoutDirection.UNDEFINED` (which is a hidden symbol)
public var layoutDirection: Int = ReactConstants.UNSET
private set
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ internal object TextLayoutManager {
if (
textAttributes.fontStyle != ReactConstants.UNSET ||
textAttributes.fontWeight != ReactConstants.UNSET ||
textAttributes.fontFeatureSettings != null ||
textAttributes.fontVariationSettings != null ||
textAttributes.fontFamily != null
) {
Expand Down Expand Up @@ -607,6 +608,7 @@ internal object TextLayoutManager {
if (
fragment.props.fontStyle != ReactConstants.UNSET ||
fragment.props.fontWeight != ReactConstants.UNSET ||
fragment.props.fontFeatureSettings != null ||
fragment.props.fontVariationSettings != null ||
fragment.props.fontFamily != null
) {
Expand Down Expand Up @@ -1003,8 +1005,8 @@ internal object TextLayoutManager {
* Sets attributes on the TextPaint, used for content outside the Spannable text, like for empty
* strings, or newlines after the last trailing character
*/
@VisibleForTesting
internal fun updateTextPaint(
@JvmStatic
fun updateTextPaint(
paint: TextPaint,
baseTextAttributes: TextAttributeProps,
assets: AssetManager,
Expand All @@ -1014,9 +1016,14 @@ internal object TextLayoutManager {
paint.textSize = baseTextAttributes.fontSize.toFloat()
}

if (baseTextAttributes.isLetterSpacingSet) {
paint.letterSpacing = baseTextAttributes.letterSpacing
}

if (
baseTextAttributes.fontStyle != ReactConstants.UNSET ||
baseTextAttributes.fontWeight != ReactConstants.UNSET ||
baseTextAttributes.fontFeatureSettings != null ||
baseTextAttributes.fontVariationSettings != null ||
baseTextAttributes.fontFamily != null
) {
Expand All @@ -1041,13 +1048,17 @@ internal object TextLayoutManager {
paint.isFakeBoldText = missingStyle and Typeface.BOLD != 0
paint.textSkewX = if ((missingStyle and Typeface.ITALIC) != 0) -0.25f else 0f
}

paint.isSubpixelText = true
paint.isLinearText = true
} else {
val typeface = ReactTypefaceUtils.applyFontWeightAdjustment(null, fontWeightAdjustment)
if (typeface != null) {
paint.setTypeface(typeface)
}
}

paint.fontFeatureSettings = baseTextAttributes.fontFeatureSettings
ReactTypefaceUtils.applyFontVariationSettings(paint, baseTextAttributes.fontVariationSettings)
}

Expand All @@ -1061,12 +1072,23 @@ internal object TextLayoutManager {
fontWeightAdjustment: Int,
): TextPaint {
val paint = checkNotNull(textPaintInstance.get())
resetTextPaint(paint, 12f)
updateTextPaint(paint, baseTextAttributes, assets, fontWeightAdjustment)
return paint
}

/** Clears every attribute [updateTextPaint] may set, so a reused paint starts from defaults. */
@JvmStatic
fun resetTextPaint(paint: TextPaint, textSize: Float) {
paint.setTypeface(null)
paint.textSize = 12f
paint.textSize = textSize
paint.letterSpacing = 0f
paint.fontFeatureSettings = null
paint.isFakeBoldText = false
paint.textSkewX = 0f
updateTextPaint(paint, baseTextAttributes, assets, fontWeightAdjustment)
return paint
paint.isSubpixelText = false
paint.isLinearText = false
ReactTypefaceUtils.applyFontVariationSettings(paint, null)
}

private fun newPaintWithAttributes(
Expand Down Expand Up @@ -1333,6 +1355,7 @@ internal object TextLayoutManager {
reactTags,
result.textBreakStrategy,
result.justificationMode,
baseTextAttributes,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,19 @@ import android.graphics.Color
import android.graphics.Paint
import android.text.SpannableString
import android.text.Spanned
import android.text.TextPaint
import android.text.TextUtils
import android.text.style.ReplacementSpan
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.createBitmap
import androidx.core.graphics.get
import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ReactStylesDiffMap
import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan
import org.assertj.core.api.Assertions.assertThat
import org.junit.After
Expand All @@ -45,6 +50,86 @@ class ReactTextViewTest {
DisplayMetricsHolder.setScreenDisplayMetrics(null)
}

@Test
fun tailEllipsisUsesBaseTextAttributeMetrics() {
val attributes =
textAttributes(
"fontFamily",
"serif",
"fontSize",
32.0,
"letterSpacing",
1.5,
"fontVariant",
JavaOnlyArray.of("small-caps"),
)
val expectedPaint = paintWithAttributes(attributes)
val view = TestReactTextView(RuntimeEnvironment.getApplication())
view.setNumberOfLines(1)
view.setEllipsizeLocation(TextUtils.TruncateAt.END)
view.setText(
textUpdate(SpannableString("Search files in a very long directory name"), attributes)
)
view.updateView()
view.measure(
View.MeasureSpec.makeMeasureSpec(40, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(60, View.MeasureSpec.EXACTLY),
)
view.layout(0, 0, 40, 60)

val paint = checkNotNull(view.layout).paint
assertThat(view.ellipsize).isEqualTo(TextUtils.TruncateAt.END)
assertThat(paint.typeface).isEqualTo(expectedPaint.typeface)
assertThat(paint.textSize).isEqualTo(expectedPaint.textSize)
assertThat(paint.letterSpacing).isEqualTo(expectedPaint.letterSpacing)
assertThat(paint.fontFeatureSettings).isEqualTo("'smcp'")
assertThat(paint.measureText("…")).isEqualTo(expectedPaint.measureText("…"))
}

@Test
fun baseTextAttributesDoNotLeakAcrossUpdatesOrRecycling() {
val text = SpannableString("Text")
val view = TestReactTextView(RuntimeEnvironment.getApplication())
val defaultTextSize = view.textSize
val custom =
textAttributes(
"fontFamily",
"serif",
"fontSize",
32.0,
"letterSpacing",
1.5,
"fontVariant",
JavaOnlyArray.of("small-caps"),
)

view.setText(textUpdate(text, custom))
assertThat(view.paint.typeface).isNotNull
assertThat(view.textSize).isNotEqualTo(defaultTextSize)

view.setText(textUpdate(text, textAttributes()))
assertDefaultPaint(view, defaultTextSize)

view.setText(textUpdate(text, custom))
view.recycleView()
assertDefaultPaint(view, defaultTextSize)
}

@Test
fun baseTextAttributesPreserveViewFontSizeAndLetterSpacingWhenUnset() {
val view = TestReactTextView(RuntimeEnvironment.getApplication())
view.setFontSize(32f)
view.letterSpacing = 1.5f
val expectedTextSize = view.textSize
val expectedLetterSpacing = view.letterSpacing

view.setText(textUpdate(SpannableString("Text"), textAttributes("fontFamily", "serif")))

assertThat(view.paint.typeface).isNotNull
assertThat(view.textSize).isEqualTo(expectedTextSize)
assertThat(view.letterSpacing).isEqualTo(expectedLetterSpacing)
}

@Test
fun drawsGlyphInkOutsideLineHeightWhenOverflowIsVisible() {
val bitmap = drawReactTextViewWithOverflow(null)
Expand Down Expand Up @@ -134,6 +219,29 @@ class ReactTextViewTest {
private fun largestAbsoluteSizeSpan(text: Spanned): Int =
text.getSpans(0, text.length, ReactAbsoluteSizeSpan::class.java).maxOfOrNull { it.size } ?: 0

private fun textAttributes(vararg keysAndValues: Any): TextAttributeProps =
TextAttributeProps.fromReadableMap(ReactStylesDiffMap(JavaOnlyMap.of(*keysAndValues)))

private fun paintWithAttributes(attributes: TextAttributeProps): TextPaint =
TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply {
TextLayoutManager.updateTextPaint(
this,
attributes,
RuntimeEnvironment.getApplication().assets,
0,
)
}

private fun textUpdate(text: Spanned, attributes: TextAttributeProps): ReactTextUpdate =
ReactTextUpdate(text, -1, Gravity.START, 0, 0, attributes)

private fun assertDefaultPaint(view: ReactTextView, defaultTextSize: Float) {
assertThat(view.paint.typeface).isNull()
assertThat(view.textSize).isEqualTo(defaultTextSize)
assertThat(view.letterSpacing).isEqualTo(0f)
assertThat(view.paint.fontFeatureSettings).isNull()
}

private fun drawReactTextViewWithOverflow(overflow: String?): Bitmap {
return drawReactTextViewWithOverflow(overflow, lineHeight = 24, viewHeight = 24, gravity = null)
}
Expand Down
Loading
Loading