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 @@ -121,7 +121,7 @@ internal object TextLayoutManager {

// 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
internal const val PIXEL_SIZE_TOLERANCE: Float = 0.001f

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

Expand Down Expand Up @@ -1497,6 +1497,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 @@ -1593,7 +1614,8 @@ internal object TextLayoutManager {
val singleLineTextExceedsWidth = text.length == 1 && layout.getLineWidth(0) > width
val exceedsHeight =
heightYogaMeasureMode != YogaMeasureMode.UNDEFINED &&
layout.height > height + PIXEL_SIZE_TOLERANCE
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 @@ -12,6 +12,7 @@ import android.text.Spanned
import android.text.TextPaint
import android.text.style.LineHeightSpan
import android.text.style.MetricAffectingSpan
import com.facebook.react.views.text.TextLayoutManager
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.max
Expand All @@ -25,10 +26,12 @@ import kotlin.math.max
* holding an inline view grow to contain it, as CSS does for inline replaced elements.
*/
internal class CustomLineHeightSpan(
height: Float,
val height: Float,
private val inlineViews: InlineViewHeights? = null,
) : LineHeightSpan.WithDensity, ReactSpan {
val lineHeight: Int = ceil(height.toDouble()).toInt()
// 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
Expand Up @@ -16,6 +16,7 @@ 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
Expand Down Expand Up @@ -64,6 +65,30 @@ class TextLayoutManagerFontSizeToFitPixelHeightTest {
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)
Expand Down Expand Up @@ -104,6 +129,7 @@ class TextLayoutManagerFontSizeToFitPixelHeightTest {

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 @@ -78,6 +78,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)
}

@Test
fun inlineViewTallerThanLineHeightGrowsLineBoxAroundTextAndView() {
val layout = buildStaticLayout("ab\uFFFC", lineHeight = 24, inlineViewHeight = 40)
Expand Down
48 changes: 48 additions & 0 deletions packages/rn-tester/js/examples/Text/TextExample.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,39 @@ function AdjustsFontSizeToFitWholePixelHeightExample(props: {}): React.Node {
);
}

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 @@ -1554,6 +1587,11 @@ const examples = [
name: 'adjustsFontSizeToFitWholePixelHeight',
render: AdjustsFontSizeToFitWholePixelHeightExample,
},
{
title: 'adjustsFontSizeToFit with lineHeight',
name: 'adjustsFontSizeToFitLineHeight',
render: AdjustsFontSizeToFitLineHeightExample,
},
{
title: 'Hyphenation',
name: 'hyphenation',
Expand Down Expand Up @@ -2081,6 +2119,16 @@ const styles = StyleSheet.create({
adjustsFontSizeToFitLabel: {
fontSize: 16,
},
adjustsFontSizeToFitLineHeightRow: {
backgroundColor: '#E4E6EB',
marginBottom: 2,
},
adjustsFontSizeToFitLineHeightWrappingRow: {
width: 90,
},
adjustsFontSizeToFitLineHeightLabel: {
fontSize: 12,
},
lineBreakSample: {
backgroundColor: '#eee',
fontSize: 16,
Expand Down
Loading