Skip to content

feat(android): render markdown as reconciled view segments - #806

Merged
jnowakow merged 5 commits into
mainfrom
feat/android-segments
Sep 16, 2026
Merged

jnowakow merged 5 commits into
mainfrom
feat/android-segments

Conversation

@jnowakow

@jnowakow jnowakow commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

What/Why?

Splits the rendered document in the standalone enriched-markdown-android package into segments hosted in a container ViewGroup, one child view per segment, instead of a single TextView. A document with no special blocks is exactly one child, so the rendered output is unchanged — this is the groundwork for block-level views that cannot be expressed as spans (tables, code blocks).

EnrichedMarkdownText becomes EnrichedMarkdownInternalText, a per-segment display view, and the new EnrichedMarkdown container owns the document-level state and callbacks. Segments are reconciled across renders by an AST signature, so unchanged children are reused (keeping selection, action mode, focus and scroll state) and only reordered via detachViewFromParent/attachViewToParent. The Compose API is untouched.

Testing

Test 1

  1. Go to the "Text" screen
  2. Scroll through the whole document
Before After
segments-main-text-screen.mov
android-segements-text-screen.mov

Rendering and link/checkbox interactions should be identical to main. Selection, "Copy as Markdown" works same as on the RN implementation - they work inside one segment.

Test 2

  1. Go to the "Playground" screen
  2. Type and edit markdown, clear the input, rotate the device
Before After
segments-main-playground.mov
segments-playground.mov

PR Checklist

  • Code compiles and runs on iOS (not applicable)
  • Code compiles and runs on Android
  • Updated documentation/README if applicable
  • Ran example app to verify changes
  • E2E tests are passing
  • Required E2E tests have been added (if applicable)

Split the rendered document into segments and host them in a container
ViewGroup, one child view per segment, instead of a single TextView. For
a document with no special blocks this is exactly one child, so the
rendered output is unchanged; it is the groundwork for block-level views
that cannot be spans.

EnrichedMarkdownText becomes EnrichedMarkdownInternalText, a per-segment
display view, and the new EnrichedMarkdown container takes over the
document-level state and callbacks. The Compose API is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLjiaibQ2arf8TRBjpUGGk
- Rename SegmentViewConfig.kt to SegmentViewCreators.kt to match the RN file layout
- Document RendererFactory.renderNodes, the flat-sibling entry point segments render through
- Drop singleSegmentRenderIsUnchanged: both sides of the assertion now run the same
  renderContent path, so it asserted nothing
- Explain why the task-index test builds its two Text segments by hand

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLjiaibQ2arf8TRBjpUGGk

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Seven moderate issues remain in rendering invalidation, callbacks, measurement, and child ordering.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR replaces the monolithic Android Markdown view with a reconciled segment-based container and updates Compose integration.

Changes:

  • Adds segment models, rendering, signatures, reconciliation, and layout.
  • Moves selection, extraction, links, images, and task-list behavior to container/child views.
  • Updates Android tests and Compose integration.

Review findings:

  • EnrichedMarkdown.kt:76 — Empty content does not clear existing children or invalidate in-flight renders. Moderate, 2 votes.
  • EnrichedMarkdown.kt:214 — Detached pending segments can become stale. Moderate, 1 vote.
  • EnrichedMarkdown.kt:241 — Link long-press callbacks can fire twice and become stale. Moderate, 1 vote.
  • EnrichedMarkdown.kt:301 — Unspecified parent width produces zero-width children. Moderate, 2 votes.
  • EnrichedMarkdown.kt:78 — Task-list toggle state can survive source changes with identical AST signatures. Moderate, 1 vote.
  • EnrichedMarkdown.kt:205 — Configuration rerenders can retain stale image dimensions. Moderate, 1 vote.
  • ContainerNodeView.kt:43 — Reused children are not reordered to match reconciled segment order. Moderate, 2 votes.
File summaries
File Description
packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/MarkdownTextViewTestSupport.kt Updates test view construction.
packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/TaskListInteractionTest.kt Tests container-level task interactions.
packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/segments/SegmentReconcilerTest.kt Tests segment reconciliation.
packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/segments/MarkdownSegmentRendererTest.kt Tests segmented rendering and task indices.
packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/EnrichedMarkdownTest.kt Tests container behavior and reuse.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionActionMode.kt Resolves styles through parent containers.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt Supports container-owned Markdown extraction.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt Targets internal text views.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewFactory.kt Defines segment view creation and updates.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/segments/SegmentViewConfig.kt Configures text segment views.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/segments/SegmentSignature.kt Adds AST-based segment signatures.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/segments/SegmentReconciler.kt Reconciles reusable segment views.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/segments/RenderedSegment.kt Defines rendered segment models.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/segments/MarkdownSegment.kt Defines AST-derived segments.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/segments/ContainerNodeView.kt Manages child layout and reconciliation.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/segments/BlockSegmentView.kt Defines segment margins.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/Renderer.kt Supports node-list rendering.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt Adds list-based node rendering.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt Removes the former monolithic implementation.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt Adds internal rendered text children.
packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt Adds the reconciled Markdown container and lifecycle.
packages/enriched-markdown-android/compose/src/main/java/com/swmansion/enriched/markdown/compose/EnrichedMarkdownText.kt Switches Compose to the native container.
Review details

Suppressed comments (4)

packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt:217

  • A render completed while the view was detached is kept in pendingSegments, but a later markdown/style/flags update does not invalidate that pending value. If the view attaches before the newer job finishes, onAttachedToWindow applies the old segments first and briefly displays stale content (and can leave it until the next job lands). Clear pending segments whenever a new render is scheduled.
  private fun scheduleRender() {
    val style = markdownStyle
    val markdown = currentMarkdown
    if (markdown.isEmpty()) return

packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt:245

  • The root injects these callbacks into each child in RootFactory, but passing them here also captures them in every LinkSpan. LinkSpan.onLongClick emits through the child and then invokes its captured callback, so every long press calls the handler twice; changing the root callback can also leave the captured handler stale. Render root segments without callback arguments and rely on the child callback fields.
            style,
            context,
            imageRequestHeaders,
            onLinkPressCallback,
            onLinkLongPressCallback,

packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt:78

  • Clearing taskListToggles changes the rendered spans without changing the AST-derived signature. If a caller supplies a different source string that parses to the same AST (for example, only a trailing newline differs) after a checkbox was toggled, reconciliation takes the exact-signature path and skips updateView, so the reused child stays checked even though the new source cleared the toggle. Mark this source change for a segment reset when toggles are present, or otherwise include this mutable state in invalidation.
    taskListToggles.clear()

packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt:205

  • A configuration change rerenders the AST, but the signature remains unchanged, so reconciliation reuses the existing text view and does not call updateView. Width-dependent ImageSpans registered on that child can therefore retain their old cached dimensions after an orientation or configuration width change. Mark configuration rerenders as a reset (or explicitly re-register/update every segment) before scheduling them.
    scheduleRenderIfNeeded()
  • Files reviewed: 22/22 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

- Empty markdown now clears the rendered children instead of leaving the previous
  document on screen with an in-flight render able to restore it
- A new render invalidates segments that landed while the view was detached
- Root text segments no longer capture the link callbacks in their spans: the child
  view already carries them, so a long press fired the handler twice, and the
  captured copy went stale when the callback was replaced
- An UNSPECIFIED parent width measures children unbounded instead of AT_MOST 0,
  which collapsed the container to zero width
- A source change after a checkbox tap, and a configuration change, now force a
  segment rebuild - both leave the AST signature untouched, so the reconciler
  would otherwise keep the stale children

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLjiaibQ2arf8TRBjpUGGk

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Four moderate issues remain in measurement, child ordering, segment splitting, and task-index reconciliation.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt:327

  • For WRAP_CONTENT/UNSPECIFIED width, desired is computed from the child width alone, but width is the outer width. measureSegmentChildren(width) then subtracts horizontal padding, so a padded container reports too little width and shrinks/clips its child; measure against available width minus padding and include both horizontal paddings in desired.
        val childWidthSpec =
          if (widthMode == MeasureSpec.UNSPECIFIED) {
            MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
          } else {
            MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.AT_MOST)
          }
        val childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
        var desired = 0
        segmentViews.forEach { child ->
          child.measure(childWidthSpec, childHeightSpec)
          desired = max(desired, child.measuredWidth)
        }
        if (widthMode == MeasureSpec.AT_MOST) min(desired, availableWidth) else desired

packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/segments/ContainerNodeView.kt:44

  • When signature matching reuses views at different positions, result.views can be reordered while the FrameLayout child list is left in its old order because these lines only remove obsolete views and append new ones. The custom layout may place pixels correctly, but ViewGroup accessibility/focus traversal and getChildAt still expose the stale order; reorder the attached children to match result.views.
    result.viewsToRemove.forEach { removeView(it) }
    result.viewsToAttach.forEach { addView(it) }

packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/segments/RenderedSegment.kt:56

  • The rendered output depends on startingTaskIndex, because each TaskListSpan stores a document-global index, but that value is omitted from the segment signature. If an earlier segment gains or loses a task item, a later segment keeps the same node signature while its task indices change; SegmentReconciler will take the exact positional match and skip updateView, leaving stale indices that can toggle the wrong source marker. Include the starting task index (or an equivalent rendered-state version) in the signature so the affected view is updated.
    val signature = SegmentSignature.signatureForNodes(nodes) xor SegmentSignature.TEXT_KIND_SALT
  • Files reviewed: 22/22 changed files
  • Comments generated: 1
  • Review effort level: Lite

Signature-based reuse can hand back the same views in a different order, in
which case nothing is attached or removed and the reused children keep their
old ViewGroup indices. segmentViews then no longer matches getChildAt, so any
index-keyed consumer - traversal, event routing, drawing order once children
can overlap - reads a stale order.

Splice the children into place with detachViewFromParent/attachViewToParent
rather than removeView/addView: neither runs the detach-from-window path, so a
moved child keeps its selection, action mode, focus and scroll state.

Applied to both packages so the standalone port and the RN view stay in sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLjiaibQ2arf8TRBjpUGGk
@jnowakow
jnowakow marked this pull request as ready for review September 14, 2026 17:24
# Conflicts:
#	packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt

@eszlamczyk eszlamczyk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@jnowakow
jnowakow merged commit cf0f1a8 into main Sep 16, 2026
12 checks passed
@jnowakow
jnowakow deleted the feat/android-segments branch September 16, 2026 06:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants