From 6ddac4cb2acf3fc89d7d0028bb2ff308d09c2d5a Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Fri, 19 Dec 2025 19:39:24 -0500 Subject: [PATCH 1/2] Fix ellipsis issue Add logs fix build Add more logs Add more logs Setup the stroke view on the text view Add some more logs Disable foreground color span when stroke is present Add more logs Update the stroke implementation Reset color filter for the fill pass Reeanable the foreground color span Improve the stroke style implementation Fix the text shadow style span Update the stroke style implementation Add leading margin for stroke/shadow that extends beyond text bounds Fix the build issue Fix the build --- .../views/text/PreparedLayoutTextView.kt | 25 +-- .../views/text/ReactBaseTextShadowNode.java | 48 ++++-- .../react/views/text/ReactTextView.java | 25 +-- .../react/views/text/ReactTextViewManager.kt | 7 + .../react/views/text/TextLayoutManager.kt | 38 ++++- .../internal/span/DiscordShadowStyleSpan.kt | 158 ------------------ .../text/internal/span/ShadowStyleSpan.kt | 13 ++ .../text/internal/span/StrokeStyleSpan.kt | 106 ++++++------ 8 files changed, 156 insertions(+), 264 deletions(-) delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index c0a9d3e147aab5..4214d3a1440f49 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -27,8 +27,8 @@ import com.facebook.proguard.annotations.DoNotStrip import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.ReactCompoundView import com.facebook.react.uimanager.style.Overflow -import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan import com.facebook.react.views.text.internal.span.ReactTagSpan +import com.facebook.react.views.text.internal.span.StrokeStyleSpan import kotlin.collections.ArrayList import kotlin.math.roundToInt @@ -102,18 +102,14 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re override fun onDraw(canvas: Canvas) { val layout = preparedLayout?.layout - // Get shadow adjustment from custom span if configured - val spanned = layout?.text as? Spanned - val shadowAdj = DiscordShadowStyleSpan.getShadowAdjustment(spanned) - - if (overflow != Overflow.VISIBLE && !shadowAdj.hasShadow) { + if (overflow != Overflow.VISIBLE) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) } super.onDraw(canvas) canvas.translate( - paddingLeft.toFloat() + shadowAdj.leftOffset, + paddingLeft.toFloat(), paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) if (layout != null) { @@ -122,10 +118,17 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re selectionColor ?: DefaultStyleValuesUtil.getDefaultTextColorHighlight(context)) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - Api34Utils.draw(layout, canvas, selection?.path, selectionPaint) - } else { - layout.draw(canvas, selection?.path, selectionPaint, 0) + val drawLayout = Runnable { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + Api34Utils.draw(layout, canvas, selection?.path, selectionPaint) + } else { + layout.draw(canvas, selection?.path, selectionPaint, 0) + } + } + + val strokeSpan = StrokeStyleSpan.getStrokeSpan(layout.text as? Spanned) + if (strokeSpan == null || !strokeSpan.draw(layout.paint, drawLayout)) { + drawLayout.run() } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index f97dbed7a34ffd..9fdc2a62b98433 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -13,7 +13,9 @@ import android.text.Layout; import android.text.Spannable; import android.text.SpannableStringBuilder; +import android.text.Spanned; import android.text.TextUtils; +import android.text.style.LeadingMarginSpan; import android.view.Gravity; import androidx.annotation.Nullable; import com.facebook.common.logging.FLog; @@ -48,7 +50,7 @@ import com.facebook.react.views.text.internal.span.ReactTagSpan; import com.facebook.react.views.text.internal.span.ReactUnderlineSpan; import com.facebook.react.views.text.internal.span.SetSpanOperation; -import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan; +import com.facebook.react.views.text.internal.span.ShadowStyleSpan; import com.facebook.react.views.text.internal.span.StrokeStyleSpan; import com.facebook.react.views.text.internal.span.TextInlineImageSpan; import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan; @@ -226,31 +228,29 @@ private static void buildSpannedFromShadowNode( if (textShadowNode.mIsLineThroughTextDecorationSet) { ops.add(new SetSpanOperation(start, end, new ReactStrikethroughSpan())); } + ShadowStyleSpan shadowSpan = null; if ((textShadowNode.mTextShadowOffsetDx != 0 || textShadowNode.mTextShadowOffsetDy != 0 || textShadowNode.mTextShadowRadius != 0) && Color.alpha(textShadowNode.mTextShadowColor) != 0) { - ops.add( - new SetSpanOperation( - start, - end, - new DiscordShadowStyleSpan( - textShadowNode.mTextShadowOffsetDx, - textShadowNode.mTextShadowOffsetDy, - textShadowNode.mTextShadowRadius, - textShadowNode.mTextShadowColor))); + shadowSpan = new ShadowStyleSpan( + textShadowNode.mTextShadowOffsetDx, + textShadowNode.mTextShadowOffsetDy, + textShadowNode.mTextShadowRadius, + textShadowNode.mTextShadowColor); + ops.add(new SetSpanOperation(start, end, shadowSpan)); } + + StrokeStyleSpan strokeSpan = null; if (!Float.isNaN(textShadowNode.mTextStrokeWidth) && textShadowNode.mTextStrokeWidth > 0 && textShadowNode.mIsTextStrokeColorSet) { - ops.add( - new SetSpanOperation( - start, - end, - new StrokeStyleSpan( - textShadowNode.mTextStrokeWidth, - textShadowNode.mTextStrokeColor))); + strokeSpan = new StrokeStyleSpan( + textShadowNode.mTextStrokeWidth, + textShadowNode.mTextStrokeColor); + ops.add(new SetSpanOperation(start, end, strokeSpan)); } + float effectiveLineHeight = textAttributes.getEffectiveLineHeight(); if (!Float.isNaN(effectiveLineHeight) && (parentTextAttributes == null @@ -335,6 +335,20 @@ protected Spannable spannedFromShadowNode( textShadowNode.mTextAttributes.setHeightOfTallestInlineViewOrImage( heightOfTallestInlineViewOrImage); + // Add leading margin for stroke/shadow that extends beyond text bounds + StrokeStyleSpan strokeSpan = StrokeStyleSpan.getStrokeSpan(sb); + ShadowStyleSpan shadowSpan = ShadowStyleSpan.getShadowSpan(sb); + float strokeOffset = strokeSpan != null ? strokeSpan.getLeftOffset() : 0f; + float shadowOffset = shadowSpan != null ? shadowSpan.getLeftOffset() : 0f; + int leadingMargin = (int) Math.max(strokeOffset, shadowOffset); + if (leadingMargin > 0) { + sb.setSpan( + new LeadingMarginSpan.Standard(leadingMargin), + 0, + sb.length(), + Spanned.SPAN_INCLUSIVE_INCLUSIVE); + } + if (mReactTextViewManagerCallback != null) { mReactTextViewManagerCallback.onPostProcessSpannable(sb); } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 05ab157865940d..472e1141415946 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -9,12 +9,14 @@ import android.content.Context; import android.graphics.Canvas; +import android.graphics.Paint; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Layout; import android.text.Spannable; import android.text.Spanned; +import android.text.TextPaint; import android.text.TextUtils; import android.text.method.LinkMovementMethod; import android.text.util.Linkify; @@ -52,8 +54,8 @@ import com.facebook.react.uimanager.style.BorderStyle; import com.facebook.react.uimanager.style.LogicalEdge; import com.facebook.react.uimanager.style.Overflow; -import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan; import com.facebook.react.views.text.internal.span.ReactTagSpan; +import com.facebook.react.views.text.internal.span.StrokeStyleSpan; import com.facebook.react.views.text.internal.span.TextInlineImageSpan; import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan; import com.facebook.yoga.YogaMeasureMode; @@ -361,19 +363,19 @@ protected void onDraw(Canvas canvas) { setText(spanned); } - // Get shadow adjustment from custom span if configured - DiscordShadowStyleSpan.ShadowAdjustment shadowAdj = - DiscordShadowStyleSpan.getShadowAdjustment(spanned); - - canvas.save(); - canvas.translate(shadowAdj.getLeftOffset(), 0); - - if (mOverflow != Overflow.VISIBLE && !shadowAdj.getHasShadow()) { + if (mOverflow != Overflow.VISIBLE) { + canvas.save(); BackgroundStyleApplicator.clipToPaddingBox(this, canvas); } - super.onDraw(canvas); - canvas.restore(); + StrokeStyleSpan strokeSpan = StrokeStyleSpan.getStrokeSpan((Spanned) getText()); + if (strokeSpan == null || !strokeSpan.draw(getPaint(), () -> super.onDraw(canvas))) { + super.onDraw(canvas); + } + + if (mOverflow != Overflow.VISIBLE) { + canvas.restore(); + } } } @@ -800,4 +802,5 @@ public void setOverflow(@Nullable String overflow) { invalidate(); } + } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index e1d41607262ebf..977f9b64bde319 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -172,6 +172,13 @@ public constructor( view.setOverflow(overflow) } + @ReactProp(name = "color", customType = "Color") + public fun setColor(view: ReactTextView, color: Int?) { + if (color != null) { + view.setTextColor(color) + } + } + public companion object { private const val TX_STATE_KEY_ATTRIBUTED_STRING: Short = 0 private const val TX_STATE_KEY_PARAGRAPH_ATTRIBUTES: Short = 1 diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 735dd1f87488eb..d85e9129a7c3b3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -21,6 +21,7 @@ import android.text.StaticLayout import android.text.TextDirectionHeuristics import android.text.TextPaint import android.text.TextUtils +import android.text.style.LeadingMarginSpan import android.util.LayoutDirection import android.view.Gravity import android.view.View @@ -47,10 +48,10 @@ import com.facebook.react.views.text.internal.span.ReactOpacitySpan import com.facebook.react.views.text.internal.span.ReactStrikethroughSpan import com.facebook.react.views.text.internal.span.ReactTagSpan import com.facebook.react.views.text.internal.span.ReactTextPaintHolderSpan -import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan +import com.facebook.react.views.text.internal.span.ShadowStyleSpan +import com.facebook.react.views.text.internal.span.StrokeStyleSpan import com.facebook.react.views.text.internal.span.ReactUnderlineSpan import com.facebook.react.views.text.internal.span.SetSpanOperation -import com.facebook.react.views.text.internal.span.StrokeStyleSpan import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan import com.facebook.yoga.YogaMeasureMode import com.facebook.yoga.YogaMeasureOutput @@ -306,7 +307,7 @@ internal object TextLayoutManager { SetSpanOperation( start, end, - DiscordShadowStyleSpan( + ShadowStyleSpan( textAttributes.mTextShadowOffsetDx, textAttributes.mTextShadowOffsetDy, textAttributes.mTextShadowRadius, @@ -315,14 +316,15 @@ internal object TextLayoutManager { if (!textAttributes.textStrokeWidth.isNaN() && textAttributes.textStrokeWidth > 0 && textAttributes.isTextStrokeColorSet) { - val strokeWidth = PixelUtil.toPixelFromDIP(textAttributes.textStrokeWidth.toDouble()).toFloat() - val strokeColor = textAttributes.textStrokeColor ops.add( SetSpanOperation( start, end, - StrokeStyleSpan(strokeWidth, strokeColor))) + StrokeStyleSpan( + textAttributes.textStrokeWidth, + textAttributes.textStrokeColor))) } + if (!textAttributes.effectiveLineHeight.isNaN()) { ops.add( SetSpanOperation( @@ -470,7 +472,7 @@ internal object TextLayoutManager { fragment.props.textShadowRadius != 0f) && Color.alpha(fragment.props.textShadowColor) != 0) { spannable.setSpan( - DiscordShadowStyleSpan( + ShadowStyleSpan( fragment.props.textShadowOffsetDx, fragment.props.textShadowOffsetDy, fragment.props.textShadowRadius, @@ -483,9 +485,10 @@ internal object TextLayoutManager { if (!fragment.props.textStrokeWidth.isNaN() && fragment.props.textStrokeWidth > 0 && fragment.props.isTextStrokeColorSet) { - val strokeWidth = PixelUtil.toPixelFromDIP(fragment.props.textStrokeWidth.toDouble()).toFloat() spannable.setSpan( - StrokeStyleSpan(strokeWidth, fragment.props.textStrokeColor), + StrokeStyleSpan( + fragment.props.textStrokeWidth, + fragment.props.textStrokeColor), start, end, spanFlags) @@ -502,9 +505,25 @@ internal object TextLayoutManager { start = end } + addLeadingMarginForTextEffects(spannable) return spannable } + private fun addLeadingMarginForTextEffects(spannable: Spannable) { + val strokeSpan = StrokeStyleSpan.getStrokeSpan(spannable) + val shadowSpan = ShadowStyleSpan.getShadowSpan(spannable) + val strokeOffset = strokeSpan?.getLeftOffset() ?: 0f + val shadowOffset = shadowSpan?.getLeftOffset() ?: 0f + val leadingMargin = max(strokeOffset, shadowOffset).toInt() + if (leadingMargin > 0) { + spannable.setSpan( + LeadingMarginSpan.Standard(leadingMargin), + 0, + spannable.length, + Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + } + fun getOrCreateSpannableForText( context: Context, attributedString: MapBuffer, @@ -555,6 +574,7 @@ internal object TextLayoutManager { op.execute(sb, priorityIndex) } + addLeadingMarginForTextEffects(sb) reactTextViewManagerCallback?.onPostProcessSpannable(sb) return sb } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt deleted file mode 100644 index 76afe866758b15..00000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright (c) Discord, 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.internal.span - -import android.graphics.Canvas -import android.graphics.Paint -import android.text.Spanned -import android.text.style.ReplacementSpan -import kotlin.math.max - -/** - * A span that applies text shadow with proper bounds calculation. - * Extends ReplacementSpan to control measurement and drawing, ensuring shadows render correctly. - * This is Discord's custom implementation that contains all shadow logic. - */ -public class DiscordShadowStyleSpan( - private val dx: Float, - private val dy: Float, - private val radius: Float, - private val color: Int -) : ReplacementSpan(), ReactSpan { - - // Getters for shadow properties (used by getShadowAdjustment) - public fun getShadowRadius(): Float = radius - public fun getShadowDx(): Float = dx - - override fun getSize( - paint: Paint, - text: CharSequence?, - start: Int, - end: Int, - fm: Paint.FontMetricsInt? - ): Int { - val width = paint.measureText(text, start, end) - - if (fm != null) { - paint.getFontMetricsInt(fm) - - val shadowTopNeeded = max(0f, radius - dy) - val shadowBottomNeeded = max(0f, radius + dy) - - val topExpansion = shadowTopNeeded.toInt() - val bottomExpansion = shadowBottomNeeded.toInt() - - // Adjust font metrics to account for shadow - fm.top -= topExpansion - fm.ascent -= topExpansion - fm.descent += bottomExpansion - fm.bottom += bottomExpansion - } - - val shadowLeftNeeded = max(0f, radius - dx) - val shadowRightNeeded = max(0f, radius + dx) - - // Subtract 1 pixel to prevent TextView ellipsization while keeping shadow mostly intact - return (width + shadowLeftNeeded + shadowRightNeeded).toInt() - 1 - } - - override fun draw( - canvas: Canvas, - text: CharSequence?, - start: Int, - end: Int, - x: Float, - top: Int, - y: Int, - bottom: Int, - paint: Paint - ) { - if (text == null) return - - val textToDraw = text.subSequence(start, end).toString() - - // Offset text to keep shadow in positive coordinates - val shadowLeftNeeded = max(0f, radius - dx) - - // Store original shadow settings - val originalShadowRadius = paint.shadowLayerRadius - val originalShadowDx = paint.shadowLayerDx - val originalShadowDy = paint.shadowLayerDy - val originalShadowColor = paint.shadowLayerColor - - paint.setShadowLayer(radius, dx, dy, color) - - if (text is Spanned && paint is android.text.TextPaint) { - val spans = text.getSpans(start, end, android.text.style.CharacterStyle::class.java) - for (span in spans) { - if (span !is DiscordShadowStyleSpan) { - span.updateDrawState(paint) - } - } - } - - // Offset text by shadowLeftNeeded to keep shadow in positive coordinates - // The view will compensate with canvas translation - canvas.drawText(textToDraw, x + shadowLeftNeeded, y.toFloat(), paint) - - // Restore original shadow settings - if (originalShadowRadius > 0f) { - paint.setShadowLayer( - originalShadowRadius, originalShadowDx, originalShadowDy, originalShadowColor) - } else { - paint.clearShadowLayer() - } - } - - /** - * Result class for shadow adjustment calculation. - * Contains the horizontal offset needed to compensate for shadow positioning - * and whether a shadow is present. - */ - public data class ShadowAdjustment( - val leftOffset: Float, - val hasShadow: Boolean - ) { - public companion object { - @JvmStatic - public val NONE: ShadowAdjustment = ShadowAdjustment(0f, false) - } - } - - public companion object { - /** - * Helper method for ReactTextView and PreparedLayoutTextView to get shadow adjustment values. - * Calculates the horizontal offset needed to compensate for shadow positioning - * when the span offsets text to keep shadows in positive coordinates. - * - * @param spanned The text to check for shadow spans, or null if no text - * @return ShadowAdjustment with negative leftOffset (ready to use in canvas.translate) - */ - @JvmStatic - public fun getShadowAdjustment(spanned: Spanned?): ShadowAdjustment { - if (spanned == null) { - return ShadowAdjustment.NONE - } - - val spans = spanned.getSpans(0, spanned.length, DiscordShadowStyleSpan::class.java) - if (spans.isEmpty()) { - return ShadowAdjustment.NONE - } - - // Use the first shadow span to calculate offset - val span = spans[0] - val radius = span.getShadowRadius() - val dx = span.getShadowDx() - // Return negative offset so views can use it directly in canvas.translate - val shadowLeftOffset = -max(0f, radius - dx) - - return ShadowAdjustment(shadowLeftOffset, true) - } - } -} - diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ShadowStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ShadowStyleSpan.kt index 638f2bb89e3acb..cb6c0bade11ffc 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ShadowStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ShadowStyleSpan.kt @@ -7,8 +7,10 @@ package com.facebook.react.views.text.internal.span +import android.text.Spanned import android.text.TextPaint import android.text.style.CharacterStyle +import kotlin.math.max internal class ShadowStyleSpan( private val dx: Float, @@ -19,4 +21,15 @@ internal class ShadowStyleSpan( override fun updateDrawState(textPaint: TextPaint) { textPaint.setShadowLayer(radius, dx, dy, color) } + + public fun getLeftOffset(): Float = max(0f, radius - dx) + + public companion object { + @JvmStatic + public fun getShadowSpan(spanned: Spanned?): ShadowStyleSpan? { + if (spanned == null) return null + val spans = spanned.getSpans(0, spanned.length, ShadowStyleSpan::class.java) + return spans.firstOrNull() + } + } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index 574038a1728d41..ac07de3b2c4da7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -1,86 +1,76 @@ +/* + * 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.internal.span -import android.graphics.Canvas import android.graphics.Paint -import android.text.style.ReplacementSpan +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.text.Spanned +import android.text.TextPaint +import android.text.style.CharacterStyle -/** - * A span that applies text stroke styling with two-pass rendering. - * First draws stroke, then draws fill on top to create outer stroke effect. - */ public class StrokeStyleSpan( - private val strokeWidth: Float, - private val strokeColor: Int -) : ReplacementSpan(), ReactSpan { + public val width: Float, + public val color: Int +) : CharacterStyle(), ReactSpan { - public override fun getSize( - paint: Paint, - text: CharSequence?, - start: Int, - end: Int, - fm: Paint.FontMetricsInt? - ): Int { - val width = paint.measureText(text, start, end) - - if (fm != null) { - paint.getFontMetricsInt(fm) - val halfStroke = (strokeWidth / 2).toInt() - fm.top -= halfStroke - fm.ascent -= halfStroke - fm.descent += halfStroke - fm.bottom += halfStroke - } - - return width.toInt() + override fun updateDrawState(textPaint: TextPaint) { + // No-op - stroke drawing is handled by the view's onDraw } - public override fun draw( - canvas: Canvas, - text: CharSequence?, - start: Int, - end: Int, - x: Float, - top: Int, - y: Int, - bottom: Int, - paint: Paint - ) { - if (text == null) return + public fun hasStroke(): Boolean = width > 0 && color != 0 + + public fun getLeftOffset(): Float = if (hasStroke()) width / 2f else 0f - val textToDraw = text.subSequence(start, end).toString() - val strokeInset = strokeWidth / 2 + public fun draw(paint: Paint, drawCallback: Runnable): Boolean { + if (!hasStroke()) { + return false + } - // Store original paint settings val originalStyle = paint.style - val originalColor = paint.color val originalStrokeWidth = paint.strokeWidth val originalStrokeJoin = paint.strokeJoin val originalStrokeCap = paint.strokeCap + val originalColor = paint.color + val originalColorFilter = paint.colorFilter - // First pass: Draw stroke only (solid color) + // Stroke pass paint.style = Paint.Style.STROKE - paint.strokeWidth = strokeWidth + paint.strokeWidth = width paint.strokeJoin = Paint.Join.ROUND paint.strokeCap = Paint.Cap.ROUND - paint.color = strokeColor - canvas.drawText(textToDraw, x + strokeInset, y.toFloat(), paint) + paint.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + drawCallback.run() - // Second pass: Draw fill on top + // Fill pass paint.style = Paint.Style.FILL + paint.strokeWidth = 0f paint.color = originalColor - if (text is android.text.Spanned && paint is android.text.TextPaint) { - val spans = text.getSpans(start, end, android.text.style.CharacterStyle::class.java) - for (span in spans) { - span.updateDrawState(paint) - } - } - canvas.drawText(textToDraw, x + strokeInset, y.toFloat(), paint) + paint.colorFilter = originalColorFilter + drawCallback.run() - // Restore original paint settings + // Restore paint.style = originalStyle - paint.color = originalColor paint.strokeWidth = originalStrokeWidth paint.strokeJoin = originalStrokeJoin paint.strokeCap = originalStrokeCap + paint.color = originalColor + paint.colorFilter = originalColorFilter + + return true + } + + public companion object { + @JvmStatic + public fun getStrokeSpan(spanned: Spanned?): StrokeStyleSpan? { + if (spanned == null) return null + val spans = spanned.getSpans(0, spanned.length, StrokeStyleSpan::class.java) + return spans.firstOrNull() + } } } From 8f5e09b6828aed4a2c2b01c365ab4be92d3bbcca Mon Sep 17 00:00:00 2001 From: Can Undeger Date: Tue, 23 Dec 2025 09:24:23 -0500 Subject: [PATCH 2/2] Remove no longer needed color prop --- .../com/facebook/react/views/text/ReactTextViewManager.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index 977f9b64bde319..e1d41607262ebf 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -172,13 +172,6 @@ public constructor( view.setOverflow(overflow) } - @ReactProp(name = "color", customType = "Color") - public fun setColor(view: ReactTextView, color: Int?) { - if (color != null) { - view.setTextColor(color) - } - } - public companion object { private const val TX_STATE_KEY_ATTRIBUTED_STRING: Short = 0 private const val TX_STATE_KEY_PARAGRAPH_ATTRIBUTES: Short = 1