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 @@ -17,12 +17,15 @@ import com.swmansion.enriched.markdown.styles.StyleConfig
import com.swmansion.enriched.markdown.utils.common.FeatureFlags
import com.swmansion.enriched.markdown.utils.common.MarkdownSegmentRenderer
import com.swmansion.enriched.markdown.utils.common.RenderedSegment
import com.swmansion.enriched.markdown.utils.common.SegmentReconciler
import com.swmansion.enriched.markdown.utils.common.StreamingMarkdownFilter
import com.swmansion.enriched.markdown.utils.common.TableStreamingMode
import com.swmansion.enriched.markdown.utils.common.splitASTIntoSegments
import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors
import com.swmansion.enriched.markdown.utils.text.view.emitLinkLongPressEvent
import com.swmansion.enriched.markdown.utils.text.view.emitLinkPressEvent
import com.swmansion.enriched.markdown.views.BlockSegmentView
import com.swmansion.enriched.markdown.views.TableContainerView
import java.util.EnumSet
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

Expand All @@ -33,12 +36,30 @@ class EnrichedMarkdown
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) {
private enum class DirtyFlag {
RECREATE_SEGMENTS,
FORCE_HEIGHT,
}

private val parser = Parser.shared
private val mainHandler = Handler(Looper.getMainLooper())
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
private val mathContainerClass: Class<*>? by lazy {
try {
Class.forName("com.swmansion.enriched.markdown.views.MathContainerView")
} catch (_: Exception) {
null
}
}

private var currentRenderId = 0L
private val segmentViews = mutableListOf<View>()
private val segmentSignatures = mutableListOf<Long>()
private val dirtyFlags = EnumSet.noneOf(DirtyFlag::class.java)
var streamingAnimation: Boolean = false

var tableStreamingMode: TableStreamingMode = TableStreamingMode.HIDDEN
private var renderPending: Boolean = false

var currentMarkdown: String = ""
private set
Expand Down Expand Up @@ -75,15 +96,25 @@ class EnrichedMarkdown
fun setMarkdownContent(markdown: String) {
if (currentMarkdown == markdown) return
currentMarkdown = markdown
scheduleRender()
renderPending = true
}

fun setMarkdownStyle(style: ReadableMap?) {
markdownStyleMap = style
val newConfig = style?.let { StyleConfig(it, context, allowFontScaling, maxFontSizeMultiplier) }
if (markdownStyle == newConfig) return
markdownStyle = newConfig
scheduleRender()
dirtyFlags += DirtyFlag.RECREATE_SEGMENTS
dirtyFlags += DirtyFlag.FORCE_HEIGHT
renderPending = true
}

fun commitProps() {
MeasurementStore.updateStreamingTableMode(id, tableStreamingMode)
if (renderPending) {
renderPending = false
scheduleRenderIfNeeded()
}
}

override fun onConfigurationChanged(newConfig: Configuration) {
Expand All @@ -93,34 +124,42 @@ class EnrichedMarkdown
if (newFontScale != lastKnownFontScale) {
lastKnownFontScale = newFontScale
recreateStyleConfig()
dirtyFlags += DirtyFlag.RECREATE_SEGMENTS
dirtyFlags += DirtyFlag.FORCE_HEIGHT
scheduleRenderIfNeeded()
}
}

fun setMd4cFlags(flags: Md4cFlags) {
if (md4cFlags == flags) return
md4cFlags = flags
scheduleRenderIfNeeded()
renderPending = true
}

fun setAllowFontScaling(allow: Boolean) {
if (allowFontScaling == allow) return
allowFontScaling = allow
recreateStyleConfig()
scheduleRenderIfNeeded()
dirtyFlags += DirtyFlag.RECREATE_SEGMENTS
dirtyFlags += DirtyFlag.FORCE_HEIGHT
renderPending = true
}

fun setMaxFontSizeMultiplier(multiplier: Float) {
if (maxFontSizeMultiplier == multiplier) return
maxFontSizeMultiplier = multiplier
recreateStyleConfig()
scheduleRenderIfNeeded()
dirtyFlags += DirtyFlag.RECREATE_SEGMENTS
dirtyFlags += DirtyFlag.FORCE_HEIGHT
renderPending = true
}

fun setAllowTrailingMargin(allow: Boolean) {
if (allowTrailingMargin == allow) return
allowTrailingMargin = allow
scheduleRenderIfNeeded()
dirtyFlags += DirtyFlag.RECREATE_SEGMENTS
dirtyFlags += DirtyFlag.FORCE_HEIGHT
renderPending = true
}

fun setIsSelectable(value: Boolean) {
Expand Down Expand Up @@ -190,14 +229,28 @@ class EnrichedMarkdown
private fun scheduleRender() {
val style = markdownStyle ?: return
val markdown = currentMarkdown.takeIf { it.isNotEmpty() } ?: return
val isStreaming = streamingAnimation
val tableMode = tableStreamingMode

val renderId = ++currentRenderId

executor.execute {
try {
val renderableMarkdown =
if (isStreaming) {
StreamingMarkdownFilter.renderableMarkdownForStreaming(markdown, tableMode)
} else {
markdown
}

if (renderableMarkdown.isEmpty()) {
postToMain(renderId) { applyRenderedSegments(emptyList(), style) }
return@execute
}

val ast =
parser.parseMarkdown(markdown, md4cFlags) ?: run {
postToMain(renderId) { clearSegments() }
parser.parseMarkdown(renderableMarkdown, md4cFlags) ?: run {
postToMain(renderId) { applyRenderedSegments(emptyList(), style) }
return@execute
}

Expand All @@ -214,7 +267,7 @@ class EnrichedMarkdown
postToMain(renderId) { applyRenderedSegments(renderedSegments, style) }
} catch (e: Exception) {
Log.e(TAG, "Render failed", e)
postToMain(renderId) { clearSegments() }
postToMain(renderId) { applyRenderedSegments(emptyList(), style) }
}
}
}
Expand All @@ -223,18 +276,130 @@ class EnrichedMarkdown
renderedSegments: List<RenderedSegment>,
style: StyleConfig,
) {
clearSegments()
renderedSegments.forEach { segment ->
val view =
when (segment) {
is RenderedSegment.Text -> createTextView(segment)
is RenderedSegment.Table -> createTableView(segment, style)
is RenderedSegment.Math -> createMathView(segment, style)
val reset = DirtyFlag.RECREATE_SEGMENTS in dirtyFlags
val forceHeight = DirtyFlag.FORCE_HEIGHT in dirtyFlags
dirtyFlags.clear()

val result =
SegmentReconciler.reconcile(
currentViews = segmentViews.toList(),
currentSignatures = segmentSignatures.toList(),
renderedSegments = renderedSegments,
reset = reset,
matchesKind = ::viewMatchesSegmentKind,
createView = { segment ->
val view = createSegmentView(segment, style)
animateNewView(view, segment)
view
},
updateView = { view, segment -> updateSegmentView(view, segment) },
)

result.viewsToRemove.forEach { removeView(it) }
result.viewsToAttach.forEach { addView(it) }

segmentViews.clear()
segmentViews.addAll(result.views)
segmentSignatures.clear()
segmentSignatures.addAll(result.signatures)

val topologyChanged = result.viewsToAttach.isNotEmpty() || result.viewsToRemove.isNotEmpty()

if (width > 0) {
val heightBefore = computeSegmentsTotalHeight()
layoutSegments()
val heightAfter = computeSegmentsTotalHeight()

if (forceHeight || topologyChanged || heightBefore != heightAfter) {
MeasurementStore.invalidate(id)
requestLayout()
}
}
}

private fun viewMatchesSegmentKind(
view: View,
segment: RenderedSegment,
): Boolean =
when (segment) {
is RenderedSegment.Text -> view is EnrichedMarkdownInternalText
is RenderedSegment.Table -> view is TableContainerView
is RenderedSegment.Math -> isMathContainerView(view)
}

private fun isMathContainerView(view: View): Boolean = mathContainerClass?.isInstance(view) == true

private fun createSegmentView(
segment: RenderedSegment,
style: StyleConfig,
): View =
when (segment) {
is RenderedSegment.Text -> createTextView(segment)
is RenderedSegment.Table -> createTableView(segment, style)
is RenderedSegment.Math -> createMathView(segment, style)
}

private fun updateSegmentView(
view: View,
segment: RenderedSegment,
) {
when (segment) {
is RenderedSegment.Text -> {
val textView = view as EnrichedMarkdownInternalText
val tailStart = textView.text?.length ?: 0
textView.lastElementMarginBottom = segment.lastElementMarginBottom
textView.applyStyledText(segment.styledText)
segment.imageSpans.forEach { it.registerTextView(textView) }
animateTextViewTail(textView, tailStart)
}

is RenderedSegment.Table -> {
val tableView = view as TableContainerView
val previousRowCount = tableView.rowCount
tableView.applyTableNode(segment.node)
if (streamingAnimation) {
tableView.animateNewRows(previousRowCount, BLOCK_FADE_DURATION_MS)
}
segmentViews.add(view)
addView(view)
}

is RenderedSegment.Math -> {
mathContainerClass
?.getMethod("applyLatex", String::class.java)
?.invoke(view, segment.latex)
}
}
layoutSegments()
}

private fun animateNewView(
view: View,
segment: RenderedSegment,
) {
if (!streamingAnimation) return
when (segment) {
is RenderedSegment.Text -> animateTextViewTail(view as EnrichedMarkdownInternalText, 0)
is RenderedSegment.Table, is RenderedSegment.Math -> animateBlockViewFadeIn(view)
}
}

private fun animateTextViewTail(
view: EnrichedMarkdownInternalText,
tailStart: Int,
) {
if (!streamingAnimation) return
val textLength = view.text?.length ?: 0
if (textLength <= tailStart) return
val animator = TailFadeInAnimator(view)
animator.animate(tailStart, textLength)
}

private fun animateBlockViewFadeIn(view: View) {
if (!streamingAnimation) return
view.alpha = 0f
view
.animate()
.alpha(1f)
.setDuration(BLOCK_FADE_DURATION_MS)
.start()
}

private fun createTextView(segment: RenderedSegment.Text) =
Expand Down Expand Up @@ -273,18 +438,18 @@ class EnrichedMarkdown
private fun createMathView(
segment: RenderedSegment.Math,
style: StyleConfig,
): android.view.View {
if (!FeatureFlags.IS_MATH_ENABLED) return android.view.View(context)
): View {
val resolvedClass = mathContainerClass
if (!FeatureFlags.IS_MATH_ENABLED || resolvedClass == null) return View(context)
return try {
val mathContainerClass = Class.forName("com.swmansion.enriched.markdown.views.MathContainerView")
val view =
mathContainerClass
.getConstructor(android.content.Context::class.java, StyleConfig::class.java)
.newInstance(context, style) as android.view.View
mathContainerClass.getMethod("applyLatex", String::class.java).invoke(view, segment.latex)
resolvedClass
.getConstructor(Context::class.java, StyleConfig::class.java)
.newInstance(context, style) as View
resolvedClass.getMethod("applyLatex", String::class.java).invoke(view, segment.latex)
view
} catch (_: Exception) {
android.view.View(context)
View(context)
}
}

Expand All @@ -297,11 +462,6 @@ class EnrichedMarkdown
}
}

private fun clearSegments() {
segmentViews.forEach { removeView(it) }
segmentViews.clear()
}

override fun onLayout(
changed: Boolean,
l: Int,
Expand Down Expand Up @@ -337,7 +497,26 @@ class EnrichedMarkdown
}
}

private fun computeSegmentsTotalHeight(): Int {
var totalHeight = 0
val lastIndex = segmentViews.lastIndex
segmentViews.forEachIndexed { index, view ->
val segment = view as? BlockSegmentView
totalHeight += segment?.segmentMarginTop ?: 0
totalHeight += view.measuredHeight
if (index != lastIndex || allowTrailingMargin) {
totalHeight += segment?.segmentMarginBottom ?: 0
}
}
return totalHeight
}

fun cleanup() {
executor.shutdownNow()
}

companion object {
private const val TAG = "EnrichedMarkdown"
private const val BLOCK_FADE_DURATION_MS = 200L
}
}
Loading