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).
internal const val PIXEL_SIZE_TOLERANCE: Float = 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 @@ -1465,6 +1465,27 @@ internal object TextLayoutManager {
)
}

/**
* How much taller rounding line heights up to whole pixels made a layout than the line heights
* its text asks for. StaticLayout applies every line height span in a paragraph to each of its
* lines, and the last one sets the line's height.
*/
private fun lineHeightRounding(layout: Layout): Float {
val text = layout.text as? Spanned ?: return 0f
var rounding = 0f
for (line in 0 until layout.lineCount) {
val lineStart = layout.getLineStart(line)
val paragraphStart = TextUtils.lastIndexOf(text, '\n', lineStart - 1) + 1
val paragraphEnd =
TextUtils.indexOf(text, '\n', lineStart).let { if (it < 0) text.length else it }
val span =
text.getSpans(paragraphStart, paragraphEnd, CustomLineHeightSpan::class.java).lastOrNull()
?: continue
rounding += span.lineHeight - span.height
}
return rounding
}

@JvmStatic
fun adjustSpannableFontToFit(
text: Spannable,
Expand Down Expand Up @@ -1560,7 +1581,9 @@ 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 &&
layout.height - lineHeightRounding(layout) > height + PIXEL_SIZE_TOLERANCE
val exceedsMaximumNumberOfLines =
maximumNumberOfLines != ReactConstants.UNSET &&
maximumNumberOfLines != 0 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package com.facebook.react.views.text.internal.span

import android.graphics.Paint.FontMetricsInt
import android.text.style.LineHeightSpan
import com.facebook.react.views.text.TextLayoutManager
import kotlin.math.ceil
import kotlin.math.floor

Expand All @@ -17,8 +18,10 @@ import kotlin.math.floor
* LineHeightSpan.Standard which only effects space between the baselines of adjacent line boxes
* (does not impact space before the first line or after the last).
*/
internal class CustomLineHeightSpan(height: Float) : LineHeightSpan, ReactSpan {
val lineHeight: Int = ceil(height.toDouble()).toInt()
internal class CustomLineHeightSpan(val height: Float) : LineHeightSpan, ReactSpan {
// Lines are laid out a whole number of pixels tall, so round the height up, but not past a whole
// number of pixels it only exceeds by floating point error.
val lineHeight: Int = ceil(height - TextLayoutManager.PIXEL_SIZE_TOLERANCE).toInt()

override fun chooseHeight(
text: CharSequence,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* 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.CustomLineHeightSpan
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)
}

@Test
fun `line heights rounded up to whole pixels still fit the height of their lines`() {
for (lineCount in 1..2) {
val text = spannableWithLineHeight(FRACTIONAL_LINE_HEIGHT, lineCount)
adjustToFitHeight(text, lineCount * FRACTIONAL_LINE_HEIGHT)

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

@Test
fun `line heights that do not fit the height of their lines shrink the font`() {
val text = spannableWithLineHeight(FRACTIONAL_LINE_HEIGHT, lineCount = 2)
adjustToFitHeight(text, 2 * FRACTIONAL_LINE_HEIGHT - 1f)

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

private fun spannableWithLineHeight(lineHeight: Float, lineCount: Int): SpannableString =
SpannableString(List(lineCount) { "Add payment method" }.joinToString("\n")).apply {
setSpan(ReactAbsoluteSizeSpan(FONT_SIZE), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
setSpan(CustomLineHeightSpan(lineHeight), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
}

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 FRACTIONAL_LINE_HEIGHT = 52.5f
const val WIDTH = 1000f
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ class CustomLineHeightSpanTest {
assertThat(layout.height).isEqualTo(24)
}

@Test
fun fractionalLineHeightRoundsUpToWholePixels() {
assertThat(CustomLineHeightSpan(42.5f).lineHeight).isEqualTo(43)
assertThat(CustomLineHeightSpan(44.01f).lineHeight).isEqualTo(45)
}

@Test
fun lineHeightFloatingPointErrorAboveWholePixelsDoesNotRoundUp() {
assertThat(CustomLineHeightSpan(Math.nextUp(44f)).lineHeight).isEqualTo(44)
assertThat(CustomLineHeightSpan(44.0004f).lineHeight).isEqualTo(44)
}

private fun buildStaticLayout(text: String, lineHeight: Int): StaticLayout {
val spannable = SpannableString(text)
spannable.setSpan(
Expand Down
94 changes: 94 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,72 @@ 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>
);
}

function AdjustsFontSizeToFitLineHeightExample(props: {}): React.Node {
// Each label's lines exactly fill its container. Android lays lines out a
// whole number of pixels tall, so it rounds up a line height that isn't one.
// Each row's line height is half a pixel taller than the last, so it
// alternates between whole and fractional pixels, and some of the whole ones
// convert to a hair over a whole number of pixels. No label should shrink.
const pixelRatio = PixelRatio.get();
const rows = [];
for (const numberOfLines of [1, 2]) {
for (let i = 0; i < 9; i++) {
const lineHeight = (Math.round(16 * pixelRatio) + i / 2) / pixelRatio;
rows.push(
<View
key={`${numberOfLines}-${i}`}
style={[
styles.adjustsFontSizeToFitLineHeightRow,
numberOfLines > 1 &&
styles.adjustsFontSizeToFitLineHeightWrappingRow,
{height: numberOfLines * lineHeight},
]}>
<Text
adjustsFontSizeToFit={true}
numberOfLines={numberOfLines}
style={[styles.adjustsFontSizeToFitLineHeightLabel, {lineHeight}]}>
Add payment method
</Text>
</View>,
);
}
}
return <View testID="text-adjusts-font-size-to-fit-line-height">{rows}</View>;
}

const examples = [
{
title: 'Wrapped text width mode',
Expand Down Expand Up @@ -1516,6 +1582,16 @@ const examples = [
name: 'fitContentWholePixelWidth',
render: FitContentWholePixelWidthExample,
},
{
title: 'adjustsFontSizeToFit at whole pixel heights',
name: 'adjustsFontSizeToFitWholePixelHeight',
render: AdjustsFontSizeToFitWholePixelHeightExample,
},
{
title: 'adjustsFontSizeToFit with lineHeight',
name: 'adjustsFontSizeToFitLineHeight',
render: AdjustsFontSizeToFitLineHeightExample,
},
{
title: 'Hyphenation',
name: 'hyphenation',
Expand Down Expand Up @@ -2028,6 +2104,24 @@ const styles = StyleSheet.create({
fontSize: 15,
maxWidth: '100%',
},
adjustsFontSizeToFitRow: {
backgroundColor: '#E4E6EB',
marginBottom: 2,
paddingTop: 7.1,
},
adjustsFontSizeToFitLabel: {
fontSize: 16,
},
adjustsFontSizeToFitLineHeightRow: {
backgroundColor: '#E4E6EB',
marginBottom: 2,
},
adjustsFontSizeToFitLineHeightWrappingRow: {
width: 90,
},
adjustsFontSizeToFitLineHeightLabel: {
fontSize: 12,
},
lineBreakSample: {
backgroundColor: '#eee',
fontSize: 16,
Expand Down
Loading