Skip to content
Merged
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 @@ -14,6 +14,7 @@ import com.swmansion.enriched.markdown.parser.Md4cFlags
import com.swmansion.enriched.markdown.parser.Parser
import com.swmansion.enriched.markdown.spoiler.SpoilerOverlay
import com.swmansion.enriched.markdown.styles.StyleConfig
import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils
import com.swmansion.enriched.markdown.utils.common.FeatureFlags
import com.swmansion.enriched.markdown.utils.common.MarkdownSegmentRenderer
import com.swmansion.enriched.markdown.utils.common.RenderedSegment
Expand Down Expand Up @@ -81,6 +82,7 @@ class EnrichedMarkdown
private var selectionColor: Int? = null
private var selectionHandleColor: Int? = null
private var selectionMenuConfig = SelectionMenuConfig()
private var textBreakStrategy: String = BreakStrategyUtils.DEFAULT_STRATEGY

private var onLinkPressCallback: ((String) -> Unit)? = null
private var onLinkLongPressCallback: ((String) -> Unit)? = null
Expand Down Expand Up @@ -185,6 +187,20 @@ class EnrichedMarkdown
applySelectionColorsToSegments()
}

fun setTextBreakStrategy(strategy: String) {
if (textBreakStrategy == strategy) return
textBreakStrategy = strategy
MeasurementStore.updateBreakStrategy(id, strategy)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val resolved = BreakStrategyUtils.resolveBreakStrategy(strategy)
segmentViews.filterIsInstance<EnrichedMarkdownInternalText>().forEach {
it.breakStrategy = resolved
}
}
dirtyFlags += DirtyFlag.FORCE_HEIGHT
renderPending = true
}

private fun applySelectionColorsToSegments() {
segmentViews.filterIsInstance<EnrichedMarkdownInternalText>().forEach {
it.applySelectionColors(selectionColor, selectionHandleColor)
Expand Down Expand Up @@ -418,6 +434,9 @@ class EnrichedMarkdown
spoilerOverlay = this@EnrichedMarkdown.spoilerOverlay
selectionMenuConfig = this@EnrichedMarkdown.selectionMenuConfig
setIsSelectable(selectable)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
breakStrategy = BreakStrategyUtils.resolveBreakStrategy(textBreakStrategy)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && segment.needsJustify) {
justificationMode = android.text.Layout.JUSTIFICATION_MODE_INTER_WORD
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class EnrichedMarkdownManager :
view.cleanup()
MeasurementStore.release(view.id)
MeasurementStore.clearStreamingTableMode(view.id)
MeasurementStore.clearBreakStrategy(view.id)
}

override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> = markdownEventTypeConstants()
Expand Down Expand Up @@ -150,6 +151,14 @@ class EnrichedMarkdownManager :
// No-op on Android — only used on iOS
}

@ReactProp(name = "lineBreakStrategyIOS")
override fun setLineBreakStrategyIOS(
view: EnrichedMarkdown?,
strategy: String?,
) {
// No-op on Android — only used on iOS
}

@ReactProp(name = "streamingAnimation", defaultBoolean = false)
override fun setStreamingAnimation(
view: EnrichedMarkdown?,
Expand Down Expand Up @@ -180,6 +189,14 @@ class EnrichedMarkdownManager :
view?.spoilerOverlay = SpoilerOverlay.fromString(mode)
}

@ReactProp(name = "textBreakStrategy")
override fun setTextBreakStrategy(
view: EnrichedMarkdown?,
strategy: String?,
) {
view?.setTextBreakStrategy(strategy ?: "highQuality")
}

@ReactProp(name = "contextMenuItems")
override fun setContextMenuItems(
view: EnrichedMarkdown?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.swmansion.enriched.markdown.spoiler.SpoilerCapable
import com.swmansion.enriched.markdown.spoiler.SpoilerOverlay
import com.swmansion.enriched.markdown.spoiler.SpoilerOverlayDrawer
import com.swmansion.enriched.markdown.styles.StyleConfig
import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils
import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
import com.swmansion.enriched.markdown.utils.text.interaction.CheckboxTouchHelper
import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMethod
Expand Down Expand Up @@ -290,6 +291,15 @@ class EnrichedMarkdownText
applySelectionColors(selectionColor, selectionHandleColor)
}

fun setTextBreakStrategy(strategy: String) {
MeasurementStore.updateBreakStrategy(id, strategy)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
breakStrategy = BreakStrategyUtils.resolveBreakStrategy(strategy)
}
MeasurementStore.invalidate(id)
scheduleRenderIfNeeded()
}

fun emitOnLinkPress(url: String) {
emitLinkPressEvent(url)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class EnrichedMarkdownTextManager :
override fun onDropViewInstance(view: EnrichedMarkdownText) {
super.onDropViewInstance(view)
MeasurementStore.clearFontScalingSettings(view.id)
MeasurementStore.clearBreakStrategy(view.id)
view.layoutManager.releaseMeasurementStore()
}

Expand Down Expand Up @@ -154,6 +155,14 @@ class EnrichedMarkdownTextManager :
// No-op on Android — only used on iOS
}

@ReactProp(name = "lineBreakStrategyIOS")
override fun setLineBreakStrategyIOS(
view: EnrichedMarkdownText?,
strategy: String?,
) {
// No-op on Android — only used on iOS
}

@ReactProp(name = "streamingAnimation", defaultBoolean = false)
override fun setStreamingAnimation(
view: EnrichedMarkdownText?,
Expand All @@ -178,6 +187,14 @@ class EnrichedMarkdownTextManager :
view?.spoilerOverlay = SpoilerOverlay.fromString(mode)
}

@ReactProp(name = "textBreakStrategy")
override fun setTextBreakStrategy(
view: EnrichedMarkdownText?,
strategy: String?,
) {
view?.setTextBreakStrategy(strategy ?: "highQuality")
}

@ReactProp(name = "contextMenuItems")
override fun setContextMenuItems(
view: EnrichedMarkdownText?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.swmansion.enriched.markdown

import android.content.Context
import android.graphics.Typeface
import android.graphics.text.LineBreaker
import android.os.Build
import android.text.SpannableString
import android.text.StaticLayout
Expand All @@ -19,6 +18,7 @@ import com.swmansion.enriched.markdown.spans.MathMeasureRequest
import com.swmansion.enriched.markdown.spans.MathMetrics
import com.swmansion.enriched.markdown.spans.MathRenderMode
import com.swmansion.enriched.markdown.styles.StyleConfig
import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils
import com.swmansion.enriched.markdown.utils.common.FeatureFlags
import com.swmansion.enriched.markdown.utils.common.MarkdownSegmentRenderer
import com.swmansion.enriched.markdown.utils.common.RenderedSegment
Expand Down Expand Up @@ -63,6 +63,8 @@ object MeasurementStore {

private val fontScalingSettings = ConcurrentHashMap<Int, FontScalingSettings>()

private val breakStrategies = ConcurrentHashMap<Int, String>()

private val streamingTableModes = ConcurrentHashMap<Int, TableStreamingMode>()

private fun resolveFontScalingSettings(
Expand Down Expand Up @@ -100,7 +102,7 @@ object MeasurementStore {
val existingHash = cached?.markdownHash ?: 0
val paintParams = PaintParams(paint.typeface ?: Typeface.DEFAULT, paint.textSize)

val newSize = measure(width, spannable, paint)
val newSize = measure(width, spannable, paint, id)
data[id] = MeasurementParams(width, newSize, spannable, paintParams, existingHash)
return oldSize != newSize
}
Expand Down Expand Up @@ -156,6 +158,19 @@ object MeasurementStore {
fontScalingSettings.remove(viewId)
}

fun updateBreakStrategy(
viewId: Int,
strategy: String,
) {
breakStrategies[viewId] = strategy
}

fun clearBreakStrategy(viewId: Int) {
breakStrategies.remove(viewId)
}

private fun resolveBreakStrategy(viewId: Int?): Int = BreakStrategyUtils.resolveBreakStrategy(viewId?.let { breakStrategies[it] })

fun updateStreamingTableMode(
viewId: Int,
mode: TableStreamingMode,
Expand Down Expand Up @@ -194,7 +209,7 @@ object MeasurementStore {

// Width changed - re-measure with cached spannable
if (cached.cachedWidth != width) {
val newSize = measure(width, cached.spannable, cached.paintParams)
val newSize = measure(width, cached.spannable, cached.paintParams, safeId)
data[safeId] = cached.copy(cachedWidth = width, cachedSize = newSize)
return newSize
}
Expand Down Expand Up @@ -286,7 +301,7 @@ object MeasurementStore {
val spannable = tryRenderMarkdown(markdown, styleMap, context, md4cFlags, allowFontScaling, maxFontSizeMultiplier)
spannable?.replaceMathSpansWithPlaceholders(context)
val textToMeasure = spannable ?: markdown
val (size, _) = measureWithLayout(width, textToMeasure, measurePaint)
val (size, _) = measureWithLayout(width, textToMeasure, measurePaint, id)

// 3. Calculate Margin
val allowTrailingMargin = props.getBooleanOrDefault("allowTrailingMargin", false)
Expand Down Expand Up @@ -407,7 +422,7 @@ object MeasurementStore {
is RenderedSegment.Text -> {
segment.styledText.replaceMathSpansWithPlaceholders(context)

val layout = createStaticLayout(segment.styledText, fontSize, widthPx)
val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id)
totalHeightPx += layout.height

val segmentMaxLineWidth = (0 until layout.lineCount).maxOfOrNull { layout.getLineWidth(it) } ?: 0f
Expand Down Expand Up @@ -456,15 +471,17 @@ object MeasurementStore {
text: CharSequence,
fontSize: Float,
widthPx: Int,
viewId: Int?,
): StaticLayout {
measurePaint.textSize = fontSize
return StaticLayout.Builder
.obtain(text, 0, text.length, measurePaint, widthPx)
.setIncludePad(false)
.setLineSpacing(0f, 1f)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setBreakStrategy(LineBreaker.BREAK_STRATEGY_HIGH_QUALITY)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@Suppress("WrongConstant")
setBreakStrategy(resolveBreakStrategy(viewId))
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setUseLineSpacingFromFallbacks(true)
Expand Down Expand Up @@ -520,17 +537,19 @@ object MeasurementStore {
maxWidth: Float,
text: CharSequence?,
paintParams: PaintParams,
viewId: Int?,
): Long {
measurePaint.reset()
measurePaint.typeface = paintParams.typeface
measurePaint.textSize = paintParams.fontSize
return measure(maxWidth, text, measurePaint)
return measure(maxWidth, text, measurePaint, viewId)
}

private fun measure(
maxWidth: Float,
text: CharSequence?,
paint: TextPaint,
viewId: Int?,
): Long {
val content = text ?: ""
val safeWidth = ceil(maxWidth).toInt().coerceAtLeast(1)
Expand All @@ -541,8 +560,9 @@ object MeasurementStore {
.setIncludePad(false)
.setLineSpacing(0f, 1f)

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.setBreakStrategy(LineBreaker.BREAK_STRATEGY_HIGH_QUALITY)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@Suppress("WrongConstant")
builder.setBreakStrategy(resolveBreakStrategy(viewId))
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Expand All @@ -568,6 +588,7 @@ object MeasurementStore {
maxWidth: Float,
text: CharSequence?,
paint: TextPaint,
viewId: Int?,
): Pair<Long, StaticLayout> {
val content = text ?: ""
val widthPx = ceil(maxWidth).toInt().coerceAtLeast(1)
Expand All @@ -578,8 +599,9 @@ object MeasurementStore {
.setIncludePad(false)
.setLineSpacing(0f, 1f)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setBreakStrategy(LineBreaker.BREAK_STRATEGY_HIGH_QUALITY)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@Suppress("WrongConstant")
setBreakStrategy(resolveBreakStrategy(viewId))
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setUseLineSpacingFromFallbacks(true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.swmansion.enriched.markdown.utils.common

import android.text.Layout

/**
* Resolves a textBreakStrategy prop value (string) to the integer constant used
* by StaticLayout.Builder.setBreakStrategy() and TextView.setBreakStrategy().
*
* Stateless on purpose: textBreakStrategy is a per-view prop. Storage lives in
* [com.swmansion.enriched.markdown.MeasurementStore] (per viewId), and each
* view applies the resolved value to its own TextView. Both measurement and
* render paths must use the same value — mismatch causes measured line count
* to differ from the rendered line count, sizing the view incorrectly and
* causing ScrollingMovementMethod (inherited via LinkMovementMethod) to
* silently scroll the overflow.
*
* Note: call sites in StaticLayout.Builder suppress "WrongConstant" lint. This is
* intentional - Layout.BREAK_STRATEGY_SIMPLE and LineBreaker.BREAK_STRATEGY_SIMPLE
* are the same integer (0), but the @IntDef annotation on StaticLayout.Builder
* .setBreakStrategy() was changed from Layout.* to LineBreaker.* in API 29.
* The suppression is safe; the Layout.* constants share the same integer values.
*/
object BreakStrategyUtils {
const val DEFAULT_STRATEGY = "highQuality"

fun resolveBreakStrategy(strategy: String?): Int =
when (strategy) {
"simple" -> Layout.BREAK_STRATEGY_SIMPLE
"balanced" -> Layout.BREAK_STRATEGY_BALANCED
else -> Layout.BREAK_STRATEGY_HIGH_QUALITY
}
}
25 changes: 25 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,31 @@ Whether to preserve the bottom margin of the last block element.
| --------- | ------------- | -------- |
| `boolean` | `false` | Both |

### `textBreakStrategy`

Controls how Android breaks lines within paragraphs. Mirrors the prop of the same name on React Native's core `Text`. The same value is used for both the measurement pass (`StaticLayout.Builder`) and the rendered `TextView`, so measured and rendered line counts stay in sync. Requires API 23+; ignored on older Android versions.

| Type | Default Value | Platform |
| --------------------------------------------- | --------------- | -------- |
| `'simple' \| 'highQuality' \| 'balanced'` | `'highQuality'` | Android |

- **`'simple'`**: greedy, no hyphenation; cheapest.
- **`'highQuality'`** (default): full paragraph optimization with hyphenation.
- **`'balanced'`**: balances line lengths across the paragraph; no hyphenation.

### `lineBreakStrategyIOS`

Controls iOS line-breaking refinements. Mirrors the prop of the same name on React Native's core `Text`. Maps to `NSParagraphStyle.lineBreakStrategy`. Requires iOS 14+; on earlier versions the prop is ignored.

| Type | Default Value | Platform |
| ---------------------------------------------------------- | ------------- | -------- |
| `'none' \| 'standard' \| 'hangul-word' \| 'push-out'` | `'none'` | iOS |

- **`'none'`** (default): no additional line-break strategy.
- **`'standard'`**: enables the system's standard line-break refinements.
- **`'hangul-word'`**: prefers breaking at Korean word boundaries.
- **`'push-out'`**: avoids orphaned short trailing lines by pushing words to the next line.

### `flavor`

Markdown flavor. Set to `'github'` to enable GitHub Flavored Markdown table support.
Expand Down
Loading
Loading