Skip to content

Draw text runs as hollow outlines (text-outline) - #142

Merged
simonhamp merged 3 commits into
mainfrom
feat/text-outline
Oct 24, 2026
Merged

simonhamp merged 3 commits into
mainfrom
feat/text-outline

Conversation

@simonhamp

@simonhamp simonhamp commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Text runs can now draw hollow: just the outline of their glyphs, in the run's own colour. This goes with NativePHP/mobile-air#549, which adds the text-outline class and passes it as the text_outline prop. I needed it for the dev/null wordmark, whose middle slash the web draws as an orange outline with -webkit-text-stroke.

How it draws

  • Android: the span or leaf gets Compose's Stroke draw style, at the width in dp. That's a real centred stroke with no fill.
  • iOS: SwiftUI can't stroke text, and its layout doesn't say which glyphs it placed. So on iOS 18 a TextRenderer sets each outlined run's text again with Core Text, in the same font, and strokes those glyph paths where SwiftUI placed each glyph. The stroke is centred on the edge with mitred corners (limit 4), the same as Android and the web.
  • Before iOS 18, outlined runs draw filled as before.
  • Nested runs inherit the width, like the other text props, and text-outline-0 turns it off.

The first version of the iOS side faked the stroke by stamping nudged copies of the glyph and cutting the inside out. The cut took out the whole glyph, so it drew a thin band outside the letter with rounded corners. The close-ups below show the difference.

Worth checking

  • The outlined run carries its font as a Core Text font, built the way its SwiftUI font is: the bundled font by name (italic face, or the same synthesized oblique), otherwise the system font at the run's weight and design. The size comes from the laid-out run, so Dynamic Type still applies. If SwiftUI ever picked a different face than that, the outline would trace the wrong glyph.
  • Overlapping contours inside a glyph are merged before stroking. SF draws letters like B and e from overlapping shapes, and without this the stroke shows lines inside them.
  • Underline, strikethrough and a background chip stay solid under an outlined run, as on Android, whose text layout switches to fill for those even when the glyphs are stroked. On iOS the outlined run is laid out with see-through glyphs and decorations in the text colour. The renderer draws the run, which leaves just those where SwiftUI puts them, then strokes the outlines on top. The leaf path now passes the text colour to underline/strikethrough explicitly, because the first decoration modifier on a Text wins. That looks the same for text that isn't outlined.
  • See-through text hides emoji too, so glyphs with no outline (emoji) are drawn from their glyphs with Core Text.
  • A composed text with an outlined run is built as several Text values joined together, because a TextRenderer can only see attributes set on Text, not on an AttributedString. Each outlined run gets its own piece. Composed text without an outline still builds the one attributed string it always did, and the custom renderer is only installed when something is outlined.
  • The renderer's displayPadding is twice the outline width, which is as far as a mitred corner can reach.

Screenshots

iOS close-ups of the wordmark at 17pt (Geist, as in the app), before and after this change, light and dark, then the same at 40pt:

Android close-ups, light and dark:

Underline, strikethrough, a chip and an emoji in outlined runs on iOS, each next to the same run filled. Before this change iOS dropped all three, and left stray bits of them at spaces:

More iOS cases: the system font, a leaf, italics, Dynamic Type at accessibility sizes, and a run that wraps with an emoji in it. Then Android in the devnull app:

iOS samples Android light Android dark

Testing

  • iOS: the new screenshots come from a small simulator app that compiles this plugin's NativeUITextRenderer, NativeUIFontResolver and NativeUITheme with stub nodes and the devnull app's Geist fonts. I ran it on iOS 27 (iPhone 17 Pro) and iOS 18.5 simulators. I haven't rebuilt the devnull app with this change yet.
  • iOS typecheck with mobile-air's render sources: the same 138 errors as before the change, all from types outside those folders, plus a planted error to prove the file was checked.
  • Android: unchanged since it was built (BUILD SUCCESSFUL) and run on an API 35 emulator.
  • For the first round of test builds, both changes were applied on top of the combined/dev-null-app branches the devnull app uses.

Docs: NativePHP/nativephp.com#565.

🤖 Generated with Claude Code

A text run with text_outline draws hollow: the outline of its glyphs, that
many points wide and centred on their edges, in the run's colour. It works
on leaf text and on runs inside composed text, and nested runs inherit it.

Android draws it with Compose's Stroke draw style. SwiftUI can't stroke
glyphs, so on iOS 18 a TextRenderer builds the outline from the glyphs:
copies nudged out by half the width make the outer edge, and the overlap
of copies nudged in, cut out of it, makes the inner edge. Text without an
outline keeps the single attributed string it always had. Before iOS 18
outlined runs draw filled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit that referenced this pull request Oct 20, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit to NativePHP/dev-null-app that referenced this pull request Oct 20, 2026
The /dev/null wordmark's middle slash is now a hollow lane-0 outline, like
the web's, using text-outline from NativePHP/mobile-air#549 and
NativePHP/mobile-ui#142. It replaces the solid regular-weight slash #20
used because EDGE text couldn't stroke a glyph then.

Main's lock already has a mobile-air combined head with #549. This moves
nativephp/mobile-ui to the combined/dev-null-app head that has #142.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit that referenced this pull request Oct 22, 2026
Brings in #149 (with #151), #152 and the final #145, plus everything else
merged to main since #144. The four conflicts were all in files where
combined's own changes (#145, #149, custom icons, the pager header and the
sticky header) are now on main in their final form, so main's version wins.
Combined still carries text-outline (#142) and the Android sheet handle on
top of main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The old iOS outline stamped nudged copies of each glyph and cut the
inside out. The cut removed the whole glyph, so what was left was a thin
band outside the letter with rounded corners. It didn't look like
Android's stroke.

Now each outlined run's text is set again with Core Text in the same
font, and the glyph paths are stroked where SwiftUI placed each glyph:
centred on the edge, mitred corners, limit 4, as on Android and the web.
Overlapping contours in a glyph (SF has them) are merged first so they
don't show inside letters. Glyphs with no outline, like emoji, draw as
they are. The font size is read from the laid-out run, so Dynamic Type
still applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simonhamp
simonhamp marked this pull request as ready for review October 24, 2026 01:10
Android draws an outlined run's background, underline and strikethrough
solid: its text layout switches the paint to fill for those even when the
glyphs are stroked. iOS dropped all three, and drew stray bits of them at
spaces, which have no outline to stroke.

Outlined runs now lay out with see-through glyphs and decorations in the
text colour. The renderer draws the run first, which leaves just its
background, underline and strikethrough where SwiftUI puts them, then
strokes the outlines on top. Emoji, which see-through text also hides,
are drawn from their glyphs with Core Text. The leaf path gives its
underline and strikethrough the text colour explicitly, because the
first decoration modifier on a Text wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simonhamp
simonhamp merged commit 0abe5d2 into main Oct 24, 2026
4 checks passed
@simonhamp
simonhamp deleted the feat/text-outline branch October 24, 2026 01:25
simonhamp added a commit to NativePHP/dev-null-app that referenced this pull request Oct 24, 2026
The /dev/null wordmark's middle slash is now a hollow lane-0 outline, like
the web's, using text-outline from NativePHP/mobile-air#549 and
NativePHP/mobile-ui#142. It replaces the solid regular-weight slash #20
used because EDGE text couldn't stroke a glyph then.

Main's lock already has a mobile-air combined head with #549. This moves
nativephp/mobile-ui to the combined/dev-null-app head that has #142.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ricardovfreixo added a commit to ricardovfreixo/mobile-ui that referenced this pull request Oct 25, 2026
Bounded merge for the companion PR NativePHP#162: takes upstream's bounded
gesture areas (NativePHP#173), the text/font/keyboard/divider/sheet fixes
(NativePHP#142, NativePHP#155, NativePHP#156, NativePHP#163, NativePHP#147, NativePHP#168, NativePHP#170) and the tab-row chrome fix
(NativePHP#167), while keeping this branch's scroll-target seam intact — the
live pager page availability guard (PagerPageContext), the ancestor
pager chain in isAvailable(), and the Android list scroll-target
registration. TabRowRenderer.kt resolves to upstream's version: both
sides had independently removed the unsupported LocalTopBarColor /
LocalTopTabRow hooks, so nothing was reintroduced and no stubs added.
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.

1 participant