@@ -17,12 +17,15 @@ import com.swmansion.enriched.markdown.styles.StyleConfig
1717import com.swmansion.enriched.markdown.utils.common.FeatureFlags
1818import com.swmansion.enriched.markdown.utils.common.MarkdownSegmentRenderer
1919import com.swmansion.enriched.markdown.utils.common.RenderedSegment
20+ import com.swmansion.enriched.markdown.utils.common.SegmentReconciler
21+ import com.swmansion.enriched.markdown.utils.common.StreamingMarkdownFilter
22+ import com.swmansion.enriched.markdown.utils.common.TableStreamingMode
2023import com.swmansion.enriched.markdown.utils.common.splitASTIntoSegments
24+ import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
2125import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors
22- import com.swmansion.enriched.markdown.utils.text.view.emitLinkLongPressEvent
23- import com.swmansion.enriched.markdown.utils.text.view.emitLinkPressEvent
2426import com.swmansion.enriched.markdown.views.BlockSegmentView
2527import com.swmansion.enriched.markdown.views.TableContainerView
28+ import java.util.EnumSet
2629import java.util.concurrent.ExecutorService
2730import java.util.concurrent.Executors
2831
@@ -33,12 +36,30 @@ class EnrichedMarkdown
3336 attrs: AttributeSet ? = null ,
3437 defStyleAttr: Int = 0 ,
3538 ) : FrameLayout (context, attrs, defStyleAttr) {
39+ private enum class DirtyFlag {
40+ RECREATE_SEGMENTS ,
41+ FORCE_HEIGHT ,
42+ }
43+
3644 private val parser = Parser .shared
3745 private val mainHandler = Handler (Looper .getMainLooper())
3846 private val executor: ExecutorService = Executors .newSingleThreadExecutor()
47+ private val mathContainerClass: Class <* >? by lazy {
48+ try {
49+ Class .forName(" com.swmansion.enriched.markdown.views.MathContainerView" )
50+ } catch (_: Exception ) {
51+ null
52+ }
53+ }
3954
4055 private var currentRenderId = 0L
4156 private val segmentViews = mutableListOf<View >()
57+ private val segmentSignatures = mutableListOf<Long >()
58+ private val dirtyFlags = EnumSet .noneOf(DirtyFlag ::class .java)
59+ var streamingAnimation: Boolean = false
60+
61+ var tableStreamingMode: TableStreamingMode = TableStreamingMode .HIDDEN
62+ private var renderPending: Boolean = false
4263
4364 var currentMarkdown: String = " "
4465 private set
@@ -75,15 +96,25 @@ class EnrichedMarkdown
7596 fun setMarkdownContent (markdown : String ) {
7697 if (currentMarkdown == markdown) return
7798 currentMarkdown = markdown
78- scheduleRender()
99+ renderPending = true
79100 }
80101
81102 fun setMarkdownStyle (style : ReadableMap ? ) {
82103 markdownStyleMap = style
83104 val newConfig = style?.let { StyleConfig (it, context, allowFontScaling, maxFontSizeMultiplier) }
84105 if (markdownStyle == newConfig) return
85106 markdownStyle = newConfig
86- scheduleRender()
107+ dirtyFlags + = DirtyFlag .RECREATE_SEGMENTS
108+ dirtyFlags + = DirtyFlag .FORCE_HEIGHT
109+ renderPending = true
110+ }
111+
112+ fun commitProps () {
113+ MeasurementStore .updateStreamingTableMode(id, tableStreamingMode)
114+ if (renderPending) {
115+ renderPending = false
116+ scheduleRenderIfNeeded()
117+ }
87118 }
88119
89120 override fun onConfigurationChanged (newConfig : Configuration ) {
@@ -93,34 +124,42 @@ class EnrichedMarkdown
93124 if (newFontScale != lastKnownFontScale) {
94125 lastKnownFontScale = newFontScale
95126 recreateStyleConfig()
127+ dirtyFlags + = DirtyFlag .RECREATE_SEGMENTS
128+ dirtyFlags + = DirtyFlag .FORCE_HEIGHT
96129 scheduleRenderIfNeeded()
97130 }
98131 }
99132
100133 fun setMd4cFlags (flags : Md4cFlags ) {
101134 if (md4cFlags == flags) return
102135 md4cFlags = flags
103- scheduleRenderIfNeeded()
136+ renderPending = true
104137 }
105138
106139 fun setAllowFontScaling (allow : Boolean ) {
107140 if (allowFontScaling == allow) return
108141 allowFontScaling = allow
109142 recreateStyleConfig()
110- scheduleRenderIfNeeded()
143+ dirtyFlags + = DirtyFlag .RECREATE_SEGMENTS
144+ dirtyFlags + = DirtyFlag .FORCE_HEIGHT
145+ renderPending = true
111146 }
112147
113148 fun setMaxFontSizeMultiplier (multiplier : Float ) {
114149 if (maxFontSizeMultiplier == multiplier) return
115150 maxFontSizeMultiplier = multiplier
116151 recreateStyleConfig()
117- scheduleRenderIfNeeded()
152+ dirtyFlags + = DirtyFlag .RECREATE_SEGMENTS
153+ dirtyFlags + = DirtyFlag .FORCE_HEIGHT
154+ renderPending = true
118155 }
119156
120157 fun setAllowTrailingMargin (allow : Boolean ) {
121158 if (allowTrailingMargin == allow) return
122159 allowTrailingMargin = allow
123- scheduleRenderIfNeeded()
160+ dirtyFlags + = DirtyFlag .RECREATE_SEGMENTS
161+ dirtyFlags + = DirtyFlag .FORCE_HEIGHT
162+ renderPending = true
124163 }
125164
126165 fun setIsSelectable (value : Boolean ) {
@@ -190,14 +229,28 @@ class EnrichedMarkdown
190229 private fun scheduleRender () {
191230 val style = markdownStyle ? : return
192231 val markdown = currentMarkdown.takeIf { it.isNotEmpty() } ? : return
232+ val isStreaming = streamingAnimation
233+ val tableMode = tableStreamingMode
193234
194235 val renderId = ++ currentRenderId
195236
196237 executor.execute {
197238 try {
239+ val renderableMarkdown =
240+ if (isStreaming) {
241+ StreamingMarkdownFilter .renderableMarkdownForStreaming(markdown, tableMode)
242+ } else {
243+ markdown
244+ }
245+
246+ if (renderableMarkdown.isEmpty()) {
247+ postToMain(renderId) { applyRenderedSegments(emptyList(), style) }
248+ return @execute
249+ }
250+
198251 val ast =
199- parser.parseMarkdown(markdown , md4cFlags) ? : run {
200- postToMain(renderId) { clearSegments( ) }
252+ parser.parseMarkdown(renderableMarkdown , md4cFlags) ? : run {
253+ postToMain(renderId) { applyRenderedSegments(emptyList(), style ) }
201254 return @execute
202255 }
203256
@@ -214,7 +267,7 @@ class EnrichedMarkdown
214267 postToMain(renderId) { applyRenderedSegments(renderedSegments, style) }
215268 } catch (e: Exception ) {
216269 Log .e(TAG , " Render failed" , e)
217- postToMain(renderId) { clearSegments( ) }
270+ postToMain(renderId) { applyRenderedSegments(emptyList(), style ) }
218271 }
219272 }
220273 }
@@ -223,18 +276,130 @@ class EnrichedMarkdown
223276 renderedSegments : List <RenderedSegment >,
224277 style : StyleConfig ,
225278 ) {
226- clearSegments()
227- renderedSegments.forEach { segment ->
228- val view =
229- when (segment) {
230- is RenderedSegment .Text -> createTextView(segment)
231- is RenderedSegment .Table -> createTableView(segment, style)
232- is RenderedSegment .Math -> createMathView(segment, style)
279+ val reset = DirtyFlag .RECREATE_SEGMENTS in dirtyFlags
280+ val forceHeight = DirtyFlag .FORCE_HEIGHT in dirtyFlags
281+ dirtyFlags.clear()
282+
283+ val result =
284+ SegmentReconciler .reconcile(
285+ currentViews = segmentViews.toList(),
286+ currentSignatures = segmentSignatures.toList(),
287+ renderedSegments = renderedSegments,
288+ reset = reset,
289+ matchesKind = ::viewMatchesSegmentKind,
290+ createView = { segment ->
291+ val view = createSegmentView(segment, style)
292+ animateNewView(view, segment)
293+ view
294+ },
295+ updateView = { view, segment -> updateSegmentView(view, segment) },
296+ )
297+
298+ result.viewsToRemove.forEach { removeView(it) }
299+ result.viewsToAttach.forEach { addView(it) }
300+
301+ segmentViews.clear()
302+ segmentViews.addAll(result.views)
303+ segmentSignatures.clear()
304+ segmentSignatures.addAll(result.signatures)
305+
306+ val topologyChanged = result.viewsToAttach.isNotEmpty() || result.viewsToRemove.isNotEmpty()
307+
308+ if (width > 0 ) {
309+ val heightBefore = computeSegmentsTotalHeight()
310+ layoutSegments()
311+ val heightAfter = computeSegmentsTotalHeight()
312+
313+ if (forceHeight || topologyChanged || heightBefore != heightAfter) {
314+ MeasurementStore .invalidate(id)
315+ requestLayout()
316+ }
317+ }
318+ }
319+
320+ private fun viewMatchesSegmentKind (
321+ view : View ,
322+ segment : RenderedSegment ,
323+ ): Boolean =
324+ when (segment) {
325+ is RenderedSegment .Text -> view is EnrichedMarkdownInternalText
326+ is RenderedSegment .Table -> view is TableContainerView
327+ is RenderedSegment .Math -> isMathContainerView(view)
328+ }
329+
330+ private fun isMathContainerView (view : View ): Boolean = mathContainerClass?.isInstance(view) == true
331+
332+ private fun createSegmentView (
333+ segment : RenderedSegment ,
334+ style : StyleConfig ,
335+ ): View =
336+ when (segment) {
337+ is RenderedSegment .Text -> createTextView(segment)
338+ is RenderedSegment .Table -> createTableView(segment, style)
339+ is RenderedSegment .Math -> createMathView(segment, style)
340+ }
341+
342+ private fun updateSegmentView (
343+ view : View ,
344+ segment : RenderedSegment ,
345+ ) {
346+ when (segment) {
347+ is RenderedSegment .Text -> {
348+ val textView = view as EnrichedMarkdownInternalText
349+ val tailStart = textView.text?.length ? : 0
350+ textView.lastElementMarginBottom = segment.lastElementMarginBottom
351+ textView.applyStyledText(segment.styledText)
352+ segment.imageSpans.forEach { it.registerTextView(textView) }
353+ animateTextViewTail(textView, tailStart)
354+ }
355+
356+ is RenderedSegment .Table -> {
357+ val tableView = view as TableContainerView
358+ val previousRowCount = tableView.rowCount
359+ tableView.applyTableNode(segment.node)
360+ if (streamingAnimation) {
361+ tableView.animateNewRows(previousRowCount, BLOCK_FADE_DURATION_MS )
233362 }
234- segmentViews.add(view)
235- addView(view)
363+ }
364+
365+ is RenderedSegment .Math -> {
366+ mathContainerClass
367+ ?.getMethod(" applyLatex" , String ::class .java)
368+ ?.invoke(view, segment.latex)
369+ }
236370 }
237- layoutSegments()
371+ }
372+
373+ private fun animateNewView (
374+ view : View ,
375+ segment : RenderedSegment ,
376+ ) {
377+ if (! streamingAnimation) return
378+ when (segment) {
379+ is RenderedSegment .Text -> animateTextViewTail(view as EnrichedMarkdownInternalText , 0 )
380+ is RenderedSegment .Table , is RenderedSegment .Math -> animateBlockViewFadeIn(view)
381+ }
382+ }
383+
384+ private fun animateTextViewTail (
385+ view : EnrichedMarkdownInternalText ,
386+ tailStart : Int ,
387+ ) {
388+ if (! streamingAnimation) return
389+ val textLength = view.text?.length ? : 0
390+ if (textLength <= tailStart) return
391+ val animator = TailFadeInAnimator (view)
392+ animator.animate(tailStart, textLength)
393+ }
394+
395+ private fun animateBlockViewFadeIn (view : View ) {
396+ if (! streamingAnimation) return
397+ view.alpha = 0f
398+ view
399+ .animate()
400+ .alpha(1f )
401+ .setDuration(BLOCK_FADE_DURATION_MS )
402+ .start()
238403 }
239404
240405 private fun createTextView (segment : RenderedSegment .Text ) =
@@ -273,18 +438,18 @@ class EnrichedMarkdown
273438 private fun createMathView (
274439 segment : RenderedSegment .Math ,
275440 style : StyleConfig ,
276- ): android.view.View {
277- if (! FeatureFlags .IS_MATH_ENABLED ) return android.view.View (context)
441+ ): View {
442+ val resolvedClass = mathContainerClass
443+ if (! FeatureFlags .IS_MATH_ENABLED || resolvedClass == null ) return View (context)
278444 return try {
279- val mathContainerClass = Class .forName(" com.swmansion.enriched.markdown.views.MathContainerView" )
280445 val view =
281- mathContainerClass
282- .getConstructor(android.content. Context ::class .java, StyleConfig ::class .java)
283- .newInstance(context, style) as android.view. View
284- mathContainerClass .getMethod(" applyLatex" , String ::class .java).invoke(view, segment.latex)
446+ resolvedClass
447+ .getConstructor(Context ::class .java, StyleConfig ::class .java)
448+ .newInstance(context, style) as View
449+ resolvedClass .getMethod(" applyLatex" , String ::class .java).invoke(view, segment.latex)
285450 view
286451 } catch (_: Exception ) {
287- android.view. View (context)
452+ View (context)
288453 }
289454 }
290455
@@ -297,11 +462,6 @@ class EnrichedMarkdown
297462 }
298463 }
299464
300- private fun clearSegments () {
301- segmentViews.forEach { removeView(it) }
302- segmentViews.clear()
303- }
304-
305465 override fun onLayout (
306466 changed : Boolean ,
307467 l : Int ,
@@ -337,7 +497,26 @@ class EnrichedMarkdown
337497 }
338498 }
339499
500+ private fun computeSegmentsTotalHeight (): Int {
501+ var totalHeight = 0
502+ val lastIndex = segmentViews.lastIndex
503+ segmentViews.forEachIndexed { index, view ->
504+ val segment = view as ? BlockSegmentView
505+ totalHeight + = segment?.segmentMarginTop ? : 0
506+ totalHeight + = view.measuredHeight
507+ if (index != lastIndex || allowTrailingMargin) {
508+ totalHeight + = segment?.segmentMarginBottom ? : 0
509+ }
510+ }
511+ return totalHeight
512+ }
513+
514+ fun cleanup () {
515+ executor.shutdownNow()
516+ }
517+
340518 companion object {
341519 private const val TAG = " EnrichedMarkdown"
520+ private const val BLOCK_FADE_DURATION_MS = 200L
342521 }
343522 }
0 commit comments