Split Java TextLayoutManager Logic (#51066)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/51066

Splits up some of the `measureText` code in Android TextLayoutManager, ahead of D73970149, which adds `measurePreparedLayout` to reuse most of this logic. Most significant change, is we pull out logic to iterate and retrieve metrics for attachments, since `measurePreparedLayout`, needs to also return dimensions, and fill into ArrayList, instead of received buffer.

Changelog: [Internal]

Reviewed By: rshest

Differential Revision: D74035246

fbshipit-source-id: 2a0f5b171c4343e26ffa5b2538a0018939b06773
This commit is contained in:
Nick Gerleman
2025-05-02 23:22:09 -07:00
committed by Facebook GitHub Bot
parent 989b3f61a0
commit 5eed872463
@@ -26,6 +26,7 @@ import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Preconditions;
import com.facebook.infer.annotation.Assertions;
import com.facebook.react.bridge.ReactNoCrashSoftException;
import com.facebook.react.bridge.ReactSoftExceptionLogger;
import com.facebook.react.bridge.WritableArray;
@@ -695,22 +696,54 @@ public class TextLayoutManager {
width,
height,
reactTextViewManagerCallback);
Spanned text = (Spanned) layout.getText();
if (text == null) {
return 0;
}
int maximumNumberOfLines =
paragraphAttributes.contains(PA_KEY_MAX_NUMBER_OF_LINES)
? paragraphAttributes.getInt(PA_KEY_MAX_NUMBER_OF_LINES)
: ReactConstants.UNSET;
int calculatedLineCount =
maximumNumberOfLines == ReactConstants.UNSET || maximumNumberOfLines == 0
? layout.getLineCount()
: Math.min(maximumNumberOfLines, layout.getLineCount());
Spanned text = (Spanned) layout.getText();
int calculatedLineCount = calculateLineCount(layout, maximumNumberOfLines);
float calculatedWidth =
calculateWidth(layout, text, width, widthYogaMeasureMode, calculatedLineCount);
float calculatedHeight =
calculateHeight(layout, text, height, heightYogaMeasureMode, calculatedLineCount);
if (attachmentsPositions != null) {
int attachmentIndex = 0;
int lastAttachmentFoundInSpan;
AttachmentMetrics metrics = new AttachmentMetrics();
for (int i = 0; i < text.length(); i = lastAttachmentFoundInSpan) {
lastAttachmentFoundInSpan =
nextAttachmentMetrics(layout, text, calculatedWidth, calculatedLineCount, i, metrics);
if (metrics.wasFound) {
attachmentsPositions[attachmentIndex] = PixelUtil.toDIPFromPixel(metrics.top);
attachmentsPositions[attachmentIndex + 1] = PixelUtil.toDIPFromPixel(metrics.left);
attachmentIndex += 2;
}
}
}
float widthInSP = PixelUtil.toDIPFromPixel(calculatedWidth);
float heightInSP = PixelUtil.toDIPFromPixel(calculatedHeight);
return YogaMeasureOutput.make(widthInSP, heightInSP);
}
private static int calculateLineCount(Layout layout, int maximumNumberOfLines) {
return maximumNumberOfLines == ReactConstants.UNSET || maximumNumberOfLines == 0
? layout.getLineCount()
: Math.min(maximumNumberOfLines, layout.getLineCount());
}
private static float calculateWidth(
Layout layout,
Spanned text,
float width,
YogaMeasureMode widthYogaMeasureMode,
int calculatedLineCount) {
// Instead of using `layout.getWidth()` (which may yield a significantly larger width for
// text that is wrapping), compute width using the longest line.
float calculatedWidth = 0;
@@ -741,7 +774,15 @@ public class TextLayoutManager {
if (android.os.Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
calculatedWidth = (float) Math.ceil(calculatedWidth);
}
return calculatedWidth;
}
private static float calculateHeight(
Layout layout,
Spanned text,
float height,
YogaMeasureMode heightYogaMeasureMode,
int calculatedLineCount) {
float calculatedHeight = height;
if (heightYogaMeasureMode != YogaMeasureMode.EXACTLY) {
// StaticLayout only seems to change its height in response to maxLines when ellipsizing, so
@@ -751,98 +792,113 @@ public class TextLayoutManager {
calculatedHeight = height;
}
}
return calculatedHeight;
}
private static class AttachmentMetrics {
boolean wasFound;
float top;
float left;
float width;
float height;
}
private static int nextAttachmentMetrics(
Layout layout,
Spanned text,
float calculatedWidth,
int calculatedLineCount,
int i,
AttachmentMetrics metrics) {
// Calculate the positions of the attachments (views) that will be rendered inside the
// Spanned Text. The following logic is only executed when a text contains views inside.
// This follows a similar logic than used in pre-fabric (see ReactTextView.onLayout method).
int attachmentIndex = 0;
int lastAttachmentFoundInSpan;
for (int i = 0; i < text.length(); i = lastAttachmentFoundInSpan) {
lastAttachmentFoundInSpan =
text.nextSpanTransition(i, text.length(), TextInlineViewPlaceholderSpan.class);
TextInlineViewPlaceholderSpan[] placeholders =
text.getSpans(i, lastAttachmentFoundInSpan, TextInlineViewPlaceholderSpan.class);
for (TextInlineViewPlaceholderSpan placeholder : placeholders) {
int start = text.getSpanStart(placeholder);
int line = layout.getLineForOffset(start);
boolean isLineTruncated = layout.getEllipsisCount(line) > 0;
boolean isAttachmentTruncated =
line > calculatedLineCount
|| (isLineTruncated
&& start >= layout.getLineStart(line) + layout.getEllipsisStart(line));
int attachmentPosition = attachmentIndex * 2;
if (isAttachmentTruncated) {
attachmentsPositions[attachmentPosition] = Float.NaN;
attachmentsPositions[attachmentPosition + 1] = Float.NaN;
attachmentIndex++;
} else {
float placeholderWidth = placeholder.getWidth();
float placeholderHeight = placeholder.getHeight();
// Calculate if the direction of the placeholder character is Right-To-Left.
boolean isRtlChar = layout.isRtlCharAt(start);
boolean isRtlParagraph = layout.getParagraphDirection(line) == Layout.DIR_RIGHT_TO_LEFT;
float placeholderLeftPosition;
// There's a bug on Samsung devices where calling getPrimaryHorizontal on
// the last offset in the layout will result in an endless loop. Work around
// this bug by avoiding getPrimaryHorizontal in that case.
if (start == text.length() - 1) {
boolean endsWithNewLine =
text.length() > 0 && text.charAt(layout.getLineEnd(line) - 1) == '\n';
float lineWidth = endsWithNewLine ? layout.getLineMax(line) : layout.getLineWidth(line);
placeholderLeftPosition =
isRtlParagraph
// Equivalent to `layout.getLineLeft(line)` but `getLineLeft` returns
// incorrect
// values when the paragraph is RTL and `setSingleLine(true)`.
? calculatedWidth - lineWidth
: layout.getLineRight(line) - placeholderWidth;
} else {
// The direction of the paragraph may not be exactly the direction the string is
// heading
// in at the
// position of the placeholder. So, if the direction of the character is the same
// as the
// paragraph
// use primary, secondary otherwise.
boolean characterAndParagraphDirectionMatch = isRtlParagraph == isRtlChar;
placeholderLeftPosition =
characterAndParagraphDirectionMatch
? layout.getPrimaryHorizontal(start)
: layout.getSecondaryHorizontal(start);
if (isRtlParagraph && !isRtlChar) {
// Adjust `placeholderLeftPosition` to work around an Android bug.
// The bug is when the paragraph is RTL and `setSingleLine(true)`, some layout
// methods such as `getPrimaryHorizontal`, `getSecondaryHorizontal`, and
// `getLineRight` return incorrect values. Their return values seem to be off
// by the same number of pixels so subtracting these values cancels out the
// error.
//
// The result is equivalent to bugless versions of
// `getPrimaryHorizontal`/`getSecondaryHorizontal`.
placeholderLeftPosition =
calculatedWidth - (layout.getLineRight(line) - placeholderLeftPosition);
}
if (isRtlChar) {
placeholderLeftPosition -= placeholderWidth;
}
}
// Vertically align the inline view to the baseline of the line of text.
float placeholderTopPosition = layout.getLineBaseline(line) - placeholderHeight;
int lastAttachmentFoundInSpan =
text.nextSpanTransition(i, text.length(), TextInlineViewPlaceholderSpan.class);
TextInlineViewPlaceholderSpan[] placeholders =
text.getSpans(i, lastAttachmentFoundInSpan, TextInlineViewPlaceholderSpan.class);
// The attachment array returns the positions of each of the attachments as
attachmentsPositions[attachmentPosition] =
PixelUtil.toDIPFromPixel(placeholderTopPosition);
attachmentsPositions[attachmentPosition + 1] =
PixelUtil.toDIPFromPixel(placeholderLeftPosition);
attachmentIndex++;
}
}
if (placeholders.length == 0) {
metrics.wasFound = false;
return lastAttachmentFoundInSpan;
}
float widthInSP = PixelUtil.toDIPFromPixel(calculatedWidth);
float heightInSP = PixelUtil.toDIPFromPixel(calculatedHeight);
Assertions.assertCondition(placeholders.length == 1);
TextInlineViewPlaceholderSpan placeholder = placeholders[0];
return YogaMeasureOutput.make(widthInSP, heightInSP);
int start = text.getSpanStart(placeholder);
int line = layout.getLineForOffset(start);
boolean isLineTruncated = layout.getEllipsisCount(line) > 0;
boolean isAttachmentTruncated =
line > calculatedLineCount
|| (isLineTruncated
&& start >= layout.getLineStart(line) + layout.getEllipsisStart(line));
if (isAttachmentTruncated) {
metrics.top = Float.NaN;
metrics.left = Float.NaN;
} else {
float placeholderWidth = placeholder.getWidth();
float placeholderHeight = placeholder.getHeight();
// Calculate if the direction of the placeholder character is Right-To-Left.
boolean isRtlChar = layout.isRtlCharAt(start);
boolean isRtlParagraph = layout.getParagraphDirection(line) == Layout.DIR_RIGHT_TO_LEFT;
float placeholderLeftPosition;
// There's a bug on Samsung devices where calling getPrimaryHorizontal on
// the last offset in the layout will result in an endless loop. Work around
// this bug by avoiding getPrimaryHorizontal in that case.
if (start == text.length() - 1) {
boolean endsWithNewLine =
text.length() > 0 && text.charAt(layout.getLineEnd(line) - 1) == '\n';
float lineWidth = endsWithNewLine ? layout.getLineMax(line) : layout.getLineWidth(line);
placeholderLeftPosition =
isRtlParagraph
// Equivalent to `layout.getLineLeft(line)` but `getLineLeft` returns
// incorrect
// values when the paragraph is RTL and `setSingleLine(true)`.
? calculatedWidth - lineWidth
: layout.getLineRight(line) - placeholderWidth;
} else {
// The direction of the paragraph may not be exactly the direction the string is
// heading
// in at the
// position of the placeholder. So, if the direction of the character is the same
// as the
// paragraph
// use primary, secondary otherwise.
boolean characterAndParagraphDirectionMatch = isRtlParagraph == isRtlChar;
placeholderLeftPosition =
characterAndParagraphDirectionMatch
? layout.getPrimaryHorizontal(start)
: layout.getSecondaryHorizontal(start);
if (isRtlParagraph && !isRtlChar) {
// Adjust `placeholderLeftPosition` to work around an Android bug.
// The bug is when the paragraph is RTL and `setSingleLine(true)`, some layout
// methods such as `getPrimaryHorizontal`, `getSecondaryHorizontal`, and
// `getLineRight` return incorrect values. Their return values seem to be off
// by the same number of pixels so subtracting these values cancels out the
// error.
//
// The result is equivalent to bugless versions of
// `getPrimaryHorizontal`/`getSecondaryHorizontal`.
placeholderLeftPosition =
calculatedWidth - (layout.getLineRight(line) - placeholderLeftPosition);
}
if (isRtlChar) {
placeholderLeftPosition -= placeholderWidth;
}
}
// Vertically align the inline view to the baseline of the line of text.
float placeholderTopPosition = layout.getLineBaseline(line) - placeholderHeight;
// The attachment array returns the positions of each of the attachments as
metrics.top = placeholderTopPosition;
metrics.left = placeholderLeftPosition;
}
metrics.wasFound = true;
metrics.width = placeholder.getWidth();
metrics.height = placeholder.getHeight();
return lastAttachmentFoundInSpan;
}
public static WritableArray measureLines(