Skip to content

fix(android): align mixed nested list indents with iOS - #1006

Closed
mwolson wants to merge 1 commit into
software-mansion:mainfrom
mwolson:swm/android-mixed-nested-list-indent
Closed

mwolson wants to merge 1 commit into
software-mansion:mainfrom
mwolson:swm/android-mixed-nested-list-indent

Conversation

@mwolson

@mwolson mwolson commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What/Why?

On Android, a nested list whose marker type differs from its parent's gets the
wrong space between marker and text. A numbered list under a bullet draws 1.
over the first letters of its items, a task list under a bullet puts its
checkboxes against the text, and bullets under a short 1. sit far from their
text. The repo's own Android baseline for mixed_nested_list_test shows it
("1Django"); the iOS baseline does not.

Closes #1004.

Nested items used a fixed marginLeft as their text indent, so their text
started from the parent's marker column while their marker was drawn from their
own. Each nested item now starts its text from its own marker column, the way
iOS already lays it out, so every parent and child combination keeps the usual
marker-to-text gap.

The change is one term in BaseListSpan.getLeadingMargin, next to the
markerExtraWidth from #1015. Each item records the column it reserves before
any widening, and its nested items get it as parentMarkerWidth. A nested
item's margin is the difference between its own truncated absolute indent and
its parent's, plus its own widening. The widenings are whole pixels, so the
Int margins and the float marker positions agree at every depth, including
tasks and bullets inside wide numbered lists. Top-level lists, and nests of the
same marker type with a whole-pixel marginLeft (the default 24dp is 63 px on a
Pixel 9), are unchanged. With a fractional pixel marginLeft, same-type nests
now alternate whole-pixel steps instead of always truncating, so their text
stays one gap from the marker instead of drifting toward it by the fraction at
each level.

Both Android renderers get the same change: react-native-enriched-markdown and
the standalone enriched-markdown-android. iOS already renders these lists
correctly and is not touched. In the React Native package the toggled copy of a
task keeps parentMarkerWidth, so toggling leaves every indent where it was.

Testing

  • ListRenderingTest in both Android packages gains the mixed-nest cases: the
    gap for all six parent and child combinations (tasks checked and unchecked),
    same-type and top-level geometry, absolute indents with fractional bullet
    sizes and margins (including a run of same-type levels before a mixed child),
    mixed ancestors followed by sibling lists, mixed nests in and around wide
    numbered lists, wrapped lines, RTL, and equal markerMinWidth columns.
    shortMixedNestsKeepTheirOriginalTextIndents pinned the old indents and is
    replaced by mixedNestsStartTheirTextFromTheirOwnMarkerColumn. The shared
    ListMarkerCanvas now records square bullets (depth 2 and deeper) and each
    paragraph's right edge and direction. With the old depth>0 margin, 6 of these
    tests fail in each package; the same-type and equal-column tests pass either
    way.
  • React Native TaskListInteractionTest: toggling a task under a bullet and
    under a number keeps every margin and drawn marker. It fails if the toggled
    copy drops parentMarkerWidth.
  • Full testDebugUnitTest, ktlintCheck and lintDebug for the standalone
    package, and the React Native package's unit tests and ktlint, pass.
  • Maestro: new Android-only mixed_nested_list_markers_test (bullets and
    numbers both ways, and task lists) with baselines, and refreshed
    mixed_nested_list_display (React Native and native Android examples) and
    list_sublist_first_combo_display. I ran every list-tagged flow in capture
    mode on main and on this branch on the repo's Pixel9-API36-EnrichedMarkdown
    AVD: those are the only captures that change, and the rebase leaves them
    byte-identical to the pre-rebase baselines. One caveat: my AVD runs on x86_64
    Linux with software rendering, and its captures of main differ from the
    committed Android baselines by anti-aliasing (identical at 10% fuzz, including
    ordered_list_wide_markers_*), and on a few flows by a shorter visible area.
    If these five baselines do not pass exact-match on your AVD, recapturing them
    there should be all they need.
  • mixed_nested_list_test now scrolls once on Android before its capture. In my
    emulator the preview was cut off after "1Django" without it, and the committed
    baseline has the gesture bar over its last row.
  • Reproduced with 1.1.1 in a blank Expo app:
    https://github.com/mwolson/tmp-enriched-markdown-issues/tree/main/android-mixed-nested-list-indent

Screenshots

React Native example on Android (mixed_nested_list_test and
mixed_nested_list_markers_test), with iOS on main for reference:

Before After iOS (unchanged)

Native Android example, mixed_nested_list_test:

Before After

PR Checklist

  • Code compiles and runs on iOS (no iOS change)
  • Code compiles and runs on Android
  • Updated documentation/README if applicable
  • Ran example app to verify changes
  • E2E tests are passing (all list flows pass in capture mode; see Testing)
  • Required E2E tests have been added (if applicable)

@juliusmarminge

Copy link
Copy Markdown
Contributor

Same note as on #1005: this PR and #1005 conflict in the Android list files. The combined resolution we shipped in the pingdotgg fork (pingdotgg#2 and #3) keeps this PR's parentMarkerWidth model and has OrderedListSpan.getBaselineMarkerWidth() return the default 99. width. Using it for whichever lands second would keep the two in sync.

@hryhoriiK97

Copy link
Copy Markdown
Collaborator

#1015 is merged (9fabe82), which carries #1005 plus the review follow-ups, and GitHub now reports this branch as conflicting. Could you rebase onto the latest main and resolve the conflicts?

What changed on main that affects this PR, so the rebase is mostly mechanical:

  • The nested-item margin is computed in BaseListSpan.getLeadingMargin in both Android packages, driven by an overridable markerExtraWidth that OrderedListSpan and TaskListSpan set. The depth>0 override in OrderedListSpan that the combined resolution dropped no longer exists, so your parentMarkerWidth term belongs in that one base method alongside the extra.
  • ListContextManager.enterList takes the list's markerExtraWidth and saves/restores it with the rest of the list state; BlockStyleContext holds listMarkerExtra, ancestorMarkerOffset and listItemMarkerExtra.
  • OrderedListSpan measures the widening through a companion measureExtraWidth, rounded to whole pixels. If you add a parent column, keep it in whole pixels too so the Int margins and float marker positions agree.
  • Tests moved: ListRenderingTest at the package root in both Android packages, with shared helpers under test/ (RN gets MarkdownRenderTestSupport, TestAstFactory and ListMarkerCanvas). shortMixedNestsKeepTheirOriginalTextIndents in the RN ListRenderingTest pins the current mixed-marker indents (52, 33, 33, 52, 44 px); it needs the new values from this PR, and your MixedNestedListIndentTest would fit well merged into ListRenderingTest rather than as a separate file.
  • TaskListTapUtils already re-adds the task's spans and its ancestors in order, so the toggle shift you mention is fixed; the toggled copy also has to carry markerExtraWidth now.

The Android ordered_list_wide_markers_test baselines on main were captured on the repo's Pixel 9 API 36 AVD and pass exact-match there, so if you recapture mixed_nested_list_display and list_sublist_first_combo_display on that AVD they should stay consistent.

@mwolson

mwolson commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for landing this! In the future if PRs are made I'll try to have the source branch be easier to update.

@mwolson

mwolson commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main (01d6139) as a single commit, following your notes:

  • The parentMarkerWidth term lives in BaseListSpan.getLeadingMargin next to
    markerExtraWidth, in both Android packages. Each span reports the column it
    reserves before widening (getBaselineMarkerWidth(): the 99. column, the
    checkbox column, or the bullet column), and nested items get their parent's.
  • Whole pixels: rather than rounding the parent column, which would move the
    float marker position, a nested margin is the difference of the two truncated
    absolute indents plus the item's own whole-pixel widening. The sum telescopes,
    so text lands at int(depth * marginLeft + column + gap) plus the ancestors'
    widenings at every depth and the marker-to-text gap stays within a pixel of
    gapWidth. One side effect to flag: with a fractional pixel marginLeft,
    same-type nests now step 24/25 px instead of always 24 px, which keeps their
    gap from drifting. With a whole-pixel margin (the default and the examples)
    they are unchanged.
  • Tests are merged into ListRenderingTest in both packages, using the shared
    helpers; ListMarkerCanvas now records square bullets and each paragraph's
    right edge and direction. shortMixedNestsKeepTheirOriginalTextIndents is
    replaced by mixedNestsStartTheirTextFromTheirOwnMarkerColumn (33, 52, 44 px
    for bullet, number and task children). The toggled TaskListSpan copy carries
    parentMarkerWidth, with a mixed-nest case in TaskListInteractionTest.

On the baselines: I used the Pixel9-API36-EnrichedMarkdown AVD from
setup-android-emulator.sh, but on x86_64 Linux with software rendering. Its
captures of main differ from the committed Android baselines by anti-aliasing
only (identical at 10% fuzz, ordered_list_wide_markers_* included). The five
baselines this PR adds or changes are byte-identical before and after the rebase
here, but they may not pass exact-match on your machine. If they don't,
recapturing those five there should be all they need.

@hryhoriiK97

hryhoriiK97 commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks again for this. We can't push to the fork branch, so the work now continues in #1023, which carries your commit unchanged (17188be on top of the current main) with a small follow-up commit:

  • getBaselineMarkerWidth() became open val baselineMarkerWidth, matching markerExtraWidth.
  • ListItemRenderer restores listItemMarkerWidth through a previousMarkerWidth local, mirroring previousMarkerExtra.
  • The five Android baselines were recaptured on the repo's Pixel9-API36-EnrichedMarkdown AVD. As you expected, your captures differed by anti-aliasing only; all four React Native flows and the native Android flow pass there now.

Both Android packages' unit tests and ktlint pass with the follow-up. This PR can be closed in favour of #1023; the fix and credit are yours.

hryhoriiK97 added a commit that referenced this pull request Oct 17, 2026
* fix(android): align mixed nested list indents with iOS

* fix(android): expose the baseline marker column as a property and recapture mixed-list baselines

Follow-up to the cherry-picked #1006 commit. `getBaselineMarkerWidth()` becomes
`open val baselineMarkerWidth` to match `markerExtraWidth`, and
`ListItemRenderer` restores `listItemMarkerWidth` through a
`previousMarkerWidth` local like `previousMarkerExtra`. The five Android
baselines are recaptured on the Pixel9-API36-EnrichedMarkdown AVD.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Mike Olson <mwolson@member.fsf.org>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.

Android draws mixed nested list markers over their text

3 participants