Skip to content

fix(android): make lineHeight work for both normal lines and headings; sync measurements - #11

Closed
hsource wants to merge 2 commits into
mainfrom
fix/android-line-height-measurement
Closed

hsource wants to merge 2 commits into
mainfrom
fix/android-line-height-measurement

Conversation

@hsource

@hsource hsource commented Sep 23, 2026 •

Copy link
Copy Markdown

Note: this PR has been manually reviewed and is a version of code that's been part of the Wanderlog (wanderlog.com) mobile apps for the past 3 weeks

What/Why?

Fixes software-mansion#615 on Android. style.lineHeight on EnrichedMarkdownTextInput is broken in two ways:

  1. Rendering: setLineHeight passed lineHeight - view.textSize to EditText.setLineSpacing. That subtracts a pixel text size from an SP line height (for example 24 - 48 = -24 on a 3x screen), so lines overlap.
  2. Measurement: InputMeasurementStore measured plain text with a bare TextPaint and no line spacing. Yoga under-measured the input, so it grew more slowly than its content and scrolled internally.

This follows how React Native's own ReactEditText handles lineHeight. It does not adjust setLineSpacing:

  • SP on TextAttributes: fontSize and lineHeight are stored in SP on a TextAttributes, so allowFontScaling and effectiveLineHeight behave like TextInput (ReactEditText L332-L334, L856-L858).
  • Body line-height span: the line height is applied to the whole buffer as a low-priority span, like the CustomLineHeightSpan in addSpansFromStyleAttributes, so markdown spans such as headings stay on top. React Native also adds font size, color and typeface spans there, because its TextLayoutManager measures with a shared paint. The editor draws with its own paint and the measurement uses a snapshot of it, so line height is the only base style that needs a span.
  • InputCssLineHeightSpan: copies CustomLineHeightSpan.chooseHeight, which splits the extra space above and below each line like CSS. It implements UpdateLayout, so EditText rebuilds its layout when the span is re-applied.
  • Headings: a heading's line height is its font size plus the body's extra leading (lineHeight - fontSize). A large heading doesn't get squeezed into the body line height, and it keeps the same spacing rhythm.
  • Measurement matches drawing: InputMeasurementStore lays out a snapshot of the editor's paint with the same line-height span and block formatting. When the buffer is empty it measures the placeholder, like TextInput does. The first measurement before the view exists (initialMeasure) reads fontSize, lineHeight and markdownStyle from props, and skips props that are missing. Changing fontSize or lineHeight at runtime reapplies formatting, so derived heading line heights update too.
  • Re-measure after formatting: Yoga is re-measured after each text-change pass and at the end of applyFormatting(), not only when the text changes. Toggling a heading or list now updates the input height. InputListItemSpacingSpan implements UpdateLayout for the same reason as the line-height span.

Compared with software-mansion#664: that PR keeps setLineSpacing and converts with toPixelFromDIP. That treats lineHeight as DP, so it ignores font scaling, and it adds all the extra space below each line. It also leaves headings at the body line height. This PR copies React Native's own implementation, so the input matches a plain TextInput with the same style.

The playground gets an LH toggle, which switches between the default line height and 36, plus an Android Maestro flow.

Testing

Summary: yarn typecheck, eslint, prettier, ktlint, Android compileDebugKotlin, and an example app build on a Pixel 6a (Android 16).

Real-world testing

This has been running in production in Wanderlog's rich text editor for the past 3 weeks and seems to work well.

Test results

line_height_input_android_test.yaml passes on a Pixel 6a. Its screenshot step was run in capture mode, because the golden has to come from the CI Pixel 9 emulator.

Left: default line height. Right: after tapping LH 36. Body and list lines are evenly spaced and don't overlap, and the editor grows until the playground's maxHeight of 200, then scrolls to keep the caret visible. That scroll is why the heading is off the top in the right-hand frame.

Scrolled back to the top at LH 36: the heading uses its derived line height (heading font size plus the body's extra leading).

Full flow recording:

Manual testing

Setup:

yarn && yarn prepare
yarn react-native-example start
yarn react-native-example android

In the example app, open Playground:

  1. Tap Set markdown and enter:
    # Heading
    Line one of body text
    - List item alpha
    Line two of body text
    
  2. Tap LH auto so it reads LH 36. Expected: lines are evenly spaced and don't overlap. The heading is taller than body lines but keeps the same spacing rhythm. The editor grows to fit all lines, with no internal scrolling (when the size mode isn't max).
  3. Tap it again to go back to LH auto. Expected: spacing returns to the default and the editor shrinks back.
  4. With LH 36 on, type a few lines and press Enter repeatedly. Expected: the editor grows one line at a time and the caret stays visible.
  5. With LH 36 on, put the caret on a body line and toggle heading (H1), then toggle it off. Do the same with a bullet list. Expected: the editor height updates right away both times, and no line keeps a stale height.
  6. Clear the input so the placeholder shows. Expected: the empty editor is one placeholder line tall at the chosen line height.
  7. Set the system font size to the largest setting (Settings, then Display, then Font size) and reopen the playground. Expected: body and heading line heights scale with the font, like a plain TextInput.
  8. Optional side-by-side check: temporarily render an RN TextInput with the same fontSize and lineHeight next to the editor. Expected: the lines match up.

Automated flow (screenshot-based; capture the golden first):

yarn test:e2e:android:update-screenshots .maestro/enrichedMarkdownInput/flows/basic/block_elements/line_height_input_android_test.yaml
yarn test:e2e:android .maestro/enrichedMarkdownInput/flows/basic/block_elements/line_height_input_android_test.yaml

PR Checklist

  • Code compiles and runs on iOS
  • 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)

hsource and others added 2 commits September 23, 2026 13:40
setLineHeight passed `lineHeight - textSize` to EditText.setLineSpacing,
mixing SP with pixels, so lines overlapped on high-density screens. It
also left Yoga measurement unaware of line height, so the input grew
slower than its content.

Follow React Native's ReactEditText instead: keep fontSize and lineHeight
in SP on TextAttributes (so allowFontScaling applies) and apply the line
height as InputCssLineHeightSpan, a copy of RN's CustomLineHeightSpan.
Headings derive their line height from the body's extra leading.
InputMeasurementStore measures a snapshot of the editor's paint with the
same line height and block spans, and with the hint when the text is
empty.

Yoga is now re-measured after every text change and formatting pass, so
toggling a heading or list updates the height. InputListItemSpacingSpan
implements UpdateLayout so EditText rebuilds the lines it affects.
The playground toggle switches the input between the default line height
and 36, to check rendering and auto-grow with headings and lists.

Co-authored-by: Cursor <cursoragent@cursor.com>
@hsource
hsource force-pushed the fix/android-line-height-measurement branch from 844c5b7 to 3ad7cce Compare September 23, 2026 20:40
@hsource hsource changed the title fix(android): lineHeight and measurement parity with RN TextInput fix(android): make lineHeight work for both normal lines and headings; sync measurements Sep 24, 2026
@hsource

hsource commented Sep 24, 2026

Copy link
Copy Markdown
Author

Moved upstream: software-mansion#885

@hsource hsource closed this Sep 24, 2026
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: editor lineHeight broken — render mixes dp/px AND auto-grow measurement ignores it

1 participant