Skip to content
Open
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 @@ -118,9 +118,9 @@ internal object TextLayoutManager {

private const val TEXT_WIDTH_MODE_LONGEST_LINE = "longest-line"

// Widths arrive in pixels converted from Yoga's floating point DIPs, so a width of a whole number
// of pixels can arrive a fraction of a pixel short of it (e.g. 371.99997 instead of 372).
private const val PIXEL_WIDTH_TOLERANCE = 0.001f
// Sizes arrive in pixels converted from Yoga's floating point DIPs, so a whole number of pixels
// can arrive a fraction of a pixel short of it (e.g. 371.99997 instead of 372).
private const val PIXEL_SIZE_TOLERANCE = 0.001f

private val tagToSpannableCache = ConcurrentHashMap<Int, Spannable>()

Expand Down Expand Up @@ -982,7 +982,7 @@ internal object TextLayoutManager {
* width back as its constraint once Yoga has converted it to DIPs and back, and flooring that to
* one pixel less wraps the text.
*/
private fun floorPixelWidth(width: Float): Int = floor(width + PIXEL_WIDTH_TOLERANCE).toInt()
private fun floorPixelWidth(width: Float): Int = floor(width + PIXEL_SIZE_TOLERANCE).toInt()

@VisibleForTesting
internal fun adjustLayoutForRtlRightOverhang(
Expand Down Expand Up @@ -1560,7 +1560,8 @@ internal object TextLayoutManager {

val singleLineTextExceedsWidth = text.length == 1 && layout.getLineWidth(0) > width
val exceedsHeight =
heightYogaMeasureMode != YogaMeasureMode.UNDEFINED && layout.height > height
heightYogaMeasureMode != YogaMeasureMode.UNDEFINED &&
layout.height > height + PIXEL_SIZE_TOLERANCE
val exceedsMaximumNumberOfLines =
maximumNumberOfLines != ReactConstants.UNSET &&
maximumNumberOfLines != 0 &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* 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.annotation.SuppressLint
import android.graphics.text.LineBreakConfig
import android.text.Layout
import android.text.SpannableString
import android.text.Spanned
import android.text.StaticLayout
import android.text.TextPaint
import com.facebook.react.common.ReactConstants
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan
import com.facebook.yoga.YogaMeasureMode
import org.assertj.core.api.Assertions.assertThat
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.GraphicsMode
import org.robolectric.annotation.TextLayoutMode

@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@TextLayoutMode(TextLayoutMode.Mode.REALISTIC)
@SuppressLint("InlinedApi")
class TextLayoutManagerFontSizeToFitPixelHeightTest {

@Before
fun setUp() {
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication())
}

@After
fun tearDown() {
DisplayMetricsHolder.setScreenDisplayMetrics(null)
}

@Test
fun `AT_MOST height a floating point error short of the text height keeps the font size`() {
val textHeight = layoutHeight(spannableWithFontSize(FONT_SIZE))

for (height in listOf(Math.nextDown(textHeight.toFloat()), textHeight - 0.0004f)) {
val text = spannableWithFontSize(FONT_SIZE)
adjustToFitHeight(text, height)

assertThat(fontSize(text)).isEqualTo(FONT_SIZE)
}
}

@Test
fun `AT_MOST height a fraction of a pixel short of the text height shrinks the font`() {
val text = spannableWithFontSize(FONT_SIZE)
adjustToFitHeight(text, layoutHeight(text) - 0.5f)

assertThat(fontSize(text)).isLessThan(FONT_SIZE)
}

private fun spannableWithFontSize(fontSize: Int): SpannableString =
SpannableString("Add payment method").apply {
setSpan(ReactAbsoluteSizeSpan(fontSize), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
}

private fun paint(): TextPaint =
TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = FONT_SIZE.toFloat() }

private fun layoutHeight(text: SpannableString): Int =
StaticLayout.Builder.obtain(text, 0, text.length, paint(), WIDTH.toInt())
.setIncludePad(false)
.build()
.height

private fun fontSize(text: SpannableString): Int =
text.getSpans(0, text.length, ReactAbsoluteSizeSpan::class.java).single().size

private fun adjustToFitHeight(text: SpannableString, height: Float) {
TextLayoutManager.adjustSpannableFontToFit(
text,
WIDTH,
YogaMeasureMode.EXACTLY,
height,
YogaMeasureMode.AT_MOST,
/* minimumFontSizeAttr = */ Float.NaN,
/* minimumFontScale = */ Float.NaN,
ReactConstants.UNSET,
/* includeFontPadding = */ false,
Layout.BREAK_STRATEGY_HIGH_QUALITY,
Layout.HYPHENATION_FREQUENCY_NONE,
LineBreakConfig.LINE_BREAK_STYLE_NONE,
LineBreakConfig.LINE_BREAK_WORD_STYLE_NONE,
Layout.Alignment.ALIGN_NORMAL,
/* justificationMode = */ 0,
paint(),
)
}

private companion object {
const val FONT_SIZE = 40
const val WIDTH = 1000f
}
}
46 changes: 46 additions & 0 deletions packages/rn-tester/js/examples/Text/TextExample.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -1415,6 +1415,39 @@ function FitContentWholePixelWidthExample(props: {}): React.Node {
return <View testID="text-fit-content-whole-pixel-width">{rows}</View>;
}

function AdjustsFontSizeToFitWholePixelHeightExample(props: {}): React.Node {
// Each label's line height is a whole number of pixels, and so is the space
// its container leaves for it below the container's padding. That space
// picks up floating point error from the padding and from the conversion to
// DIPs and back, so it can fall a hair short of the line height. Each row is
// a pixel taller than the last. No label should shrink.
const pixelRatio = PixelRatio.get();
const rows = [];
for (let i = 0; i < 17; i++) {
const lineHeight = (Math.round(20 * pixelRatio) + i) / pixelRatio;
rows.push(
<View
key={i}
style={[
styles.adjustsFontSizeToFitRow,
{height: lineHeight + styles.adjustsFontSizeToFitRow.paddingTop},
]}>
<Text
adjustsFontSizeToFit={true}
numberOfLines={1}
style={[styles.adjustsFontSizeToFitLabel, {lineHeight}]}>
Add payment method
</Text>
</View>,
);
}
return (
<View testID="text-adjusts-font-size-to-fit-whole-pixel-height">
{rows}
</View>
);
}

const examples = [
{
title: 'Wrapped text width mode',
Expand Down Expand Up @@ -1516,6 +1549,11 @@ const examples = [
name: 'fitContentWholePixelWidth',
render: FitContentWholePixelWidthExample,
},
{
title: 'adjustsFontSizeToFit at whole pixel heights',
name: 'adjustsFontSizeToFitWholePixelHeight',
render: AdjustsFontSizeToFitWholePixelHeightExample,
},
{
title: 'Hyphenation',
name: 'hyphenation',
Expand Down Expand Up @@ -2028,6 +2066,14 @@ const styles = StyleSheet.create({
fontSize: 15,
maxWidth: '100%',
},
adjustsFontSizeToFitRow: {
backgroundColor: '#E4E6EB',
marginBottom: 2,
paddingTop: 7.1,
},
adjustsFontSizeToFitLabel: {
fontSize: 16,
},
lineBreakSample: {
backgroundColor: '#eee',
fontSize: 16,
Expand Down
Loading