Skip to content

Commit 936d30a

Browse files
committed
Extend the measured height of text by strokeWidth.
1 parent 50c6d32 commit 936d30a

3 files changed

Lines changed: 72 additions & 4 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.facebook.react.uimanager.annotations.ReactProp
3333
import com.facebook.react.uimanager.events.RCTEventEmitter
3434
import com.facebook.react.views.text.FontMetricsUtil.getFontMetrics
3535
import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan
36+
import com.facebook.react.views.text.internal.span.StrokeStyleSpan
3637
import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan
3738
import com.facebook.yoga.YogaBaselineFunction
3839
import com.facebook.yoga.YogaConstants
@@ -144,6 +145,25 @@ public constructor(reactTextViewManagerCallback: ReactTextViewManagerCallback? =
144145
layoutHeight = height
145146
}
146147
}
148+
149+
// Reserve room for the text stroke halo so it isn't clipped on multi-line text. Matches the
150+
// Fabric path (TextLayoutManager.applyStrokePadding) and iOS (RCTTextShadowView.mm).
151+
val strokeWidth = StrokeStyleSpan.getMaxStrokeWidth(text)
152+
if (strokeWidth > 0f) {
153+
if (widthMode != YogaMeasureMode.EXACTLY) {
154+
layoutWidth += strokeWidth
155+
if (widthMode == YogaMeasureMode.AT_MOST && layoutWidth > width) {
156+
layoutWidth = width
157+
}
158+
}
159+
if (heightMode != YogaMeasureMode.EXACTLY) {
160+
layoutHeight += strokeWidth
161+
if (heightMode == YogaMeasureMode.AT_MOST && layoutHeight > height) {
162+
layoutHeight = height
163+
}
164+
}
165+
}
166+
147167
YogaMeasureOutput.make(layoutWidth, layoutHeight)
148168
}
149169

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -991,8 +991,14 @@ internal object TextLayoutManager {
991991
}
992992
}
993993

994-
val widthInSP = calculatedWidth.pxToDp()
995-
val heightInSP = calculatedHeight.pxToDp()
994+
// Reserve room for the text stroke halo so it isn't clipped, matching iOS behavior in
995+
// RCTTextShadowView.mm. Respects EXACTLY/AT_MOST constraints so parents that asked for a
996+
// specific size still get one.
997+
val paddedWidth = applyStrokePadding(calculatedWidth, text, width, widthYogaMeasureMode)
998+
val paddedHeight = applyStrokePadding(calculatedHeight, text, height, heightYogaMeasureMode)
999+
1000+
val widthInSP = paddedWidth.pxToDp()
1001+
val heightInSP = paddedHeight.pxToDp()
9961002

9971003
return YogaMeasureOutput.make(widthInSP, heightInSP)
9981004
}
@@ -1015,9 +1021,14 @@ internal object TextLayoutManager {
10151021
val calculatedHeight =
10161022
calculateHeight(layout, height, heightYogaMeasureMode, calculatedLineCount)
10171023

1024+
// Reserve room for the text stroke halo so it isn't clipped, matching iOS behavior in
1025+
// RCTTextShadowView.mm. Respects EXACTLY/AT_MOST constraints.
1026+
val paddedWidth = applyStrokePadding(calculatedWidth, text, width, widthYogaMeasureMode)
1027+
val paddedHeight = applyStrokePadding(calculatedHeight, text, height, heightYogaMeasureMode)
1028+
10181029
val retList = ArrayList<Float>()
1019-
retList.add(calculatedWidth.pxToDp())
1020-
retList.add(calculatedHeight.pxToDp())
1030+
retList.add(paddedWidth.pxToDp())
1031+
retList.add(paddedHeight.pxToDp())
10211032

10221033
val metrics = AttachmentMetrics()
10231034
var lastAttachmentFoundInSpan: Int
@@ -1125,6 +1136,25 @@ internal object TextLayoutManager {
11251136
return calculatedHeight
11261137
}
11271138

1139+
/**
1140+
* Adds stroke width padding to a measured dimension so the stroke halo isn't clipped. Mirrors
1141+
* iOS behavior in [RCTTextShadowView.mm] where `size.width`/`size.height` are expanded by the
1142+
* stroke width before clamping to the container's max size. Returns [dimension] unchanged when
1143+
* no [StrokeStyleSpan] is present or when Yoga requested an EXACTLY-sized box.
1144+
*/
1145+
private fun applyStrokePadding(
1146+
dimension: Float,
1147+
text: Spanned,
1148+
constraint: Float,
1149+
measureMode: YogaMeasureMode
1150+
): Float {
1151+
if (measureMode == YogaMeasureMode.EXACTLY) return dimension
1152+
val strokeWidth = StrokeStyleSpan.getMaxStrokeWidth(text)
1153+
if (strokeWidth <= 0f) return dimension
1154+
val padded = dimension + strokeWidth
1155+
return if (measureMode == YogaMeasureMode.AT_MOST) min(padded, constraint) else padded
1156+
}
1157+
11281158
private fun nextAttachmentMetrics(
11291159
layout: Layout,
11301160
text: Spanned,

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,23 @@ public class StrokeStyleSpan(
7272
val spans = spanned.getSpans(0, spanned.length, StrokeStyleSpan::class.java)
7373
return spans.firstOrNull()
7474
}
75+
76+
/**
77+
* Returns the maximum stroke width across all [StrokeStyleSpan]s in [spanned], or 0 if none are
78+
* present. Used by the measurement pipeline to reserve room for the stroke halo so it isn't
79+
* clipped on multi-line text (matches iOS behavior in RCTTextShadowView.mm).
80+
*/
81+
@JvmStatic
82+
public fun getMaxStrokeWidth(spanned: Spanned?): Float {
83+
if (spanned == null) return 0f
84+
val spans = spanned.getSpans(0, spanned.length, StrokeStyleSpan::class.java)
85+
var maxWidth = 0f
86+
for (span in spans) {
87+
if (span.width > maxWidth) {
88+
maxWidth = span.width
89+
}
90+
}
91+
return maxWidth
92+
}
7593
}
7694
}

0 commit comments

Comments
 (0)