Skip to content

iOS: draw clamp-mode text gradients as a single clamped fill#10

Draft
andrewkunkel wants to merge 5 commits into
0.81.4-discordfrom
claude/ios-clamp-gradient-text-9lj21j
Draft

iOS: draw clamp-mode text gradients as a single clamped fill#10
andrewkunkel wants to merge 5 commits into
0.81.4-discordfrom
claude/ios-clamp-gradient-text-9lj21j

Conversation

@andrewkunkel

Copy link
Copy Markdown

Summary:

Vertical gradient text with gradientMode: "clamp" was effectively a no-op on iOS. The real defect: clamp mode only skipped appending the mirror color, but the fill was still applied via UIColor colorWithPatternImage:, which tiles the gradient image in both axes. Descenders (and any glyph area past the pattern) got a wrapped tile instead of the clamped edge color — unlike Android, where gradientMode: "clamp" maps to Shader.TileMode.CLAMP.

The fix makes iOS clamp match Android: draw the clamp-mode fill as a single clipped gradient instead of a tiled pattern. Because the gradient is clipped to the glyph outlines and clamps past its endpoints, descenders below the last stop now get the edge color. This also fixes descenders for Gradient/Prism/Pop, not just Toon.

Mirror mode is unchanged — it keeps the existing pattern-image path.

What changed

  • RCTAttributedTextUtils.mm (RCTEffectiveForegroundColorFromTextAttributes / RCTNSTextAttributesFromTextAttributes): for clamp mode, carry the gradient params as attributes (RCTTextGradientColors, an NSArray of CGColorRef, and RCTTextGradientAngle), mirroring the existing RCTTextStrokeWidth pattern. A solid fallback foreground (the last/edge color) is still set so measurement and any non-custom draw path stay correct. Opacity is applied to the carried stop colors so it isn't lost.

  • RCTTextLayoutManager.mm (fill portion of -drawAttributedString:): when the gradient attributes are present, replace the kCGTextFill pass with a clip-then-gradient pass:

    • kCGTextClip + the same CTM setup + CTFrameDraw accumulate the glyph outlines into the clip.
    • Then a single CGContextDrawLinearGradient is drawn, sized to usedRectForTextContainer (which includes the descent, guaranteeing descenders are covered), with kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation — the iOS equivalent of Shader.TileMode.CLAMP, so pixels past the gradient ends take the edge color instead of wrapping.
    • Start/end points are derived from the angle using the same normalized convention as mirror mode, expressed in the flipped CoreText space so the visual top of the text is at the highest y.
    • The stroke-shift computation was hoisted so the fill path works both with and without a text stroke (shifts are 0 with no stroke, so the fill lands exactly where drawGlyphsForGlyphRange: would have placed it).

Changelog:

[IOS] [FIXED] - Fix gradientMode: "clamp" for text so vertical gradients clamp (instead of tiling) and descenders render the edge color

Test Plan:

⚠️ Draft — not yet exercised on device. Intended verification:

  • Text with a vertical gradient fill and gradientMode: "clamp" renders as a single top-to-bottom gradient with no vertical tiling/wrapping.
  • Descenders (e.g. g, y, p, q, j) render the clamped edge color rather than a re-tiled band, for Gradient/Prism/Pop/Toon.
  • Mirror mode (default) is visually unchanged.
  • Gradient text combined with a text stroke still renders stroke + gradient fill correctly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DCnmaWsXepFBSergf5ca3c


Generated by Claude Code

claude added 5 commits July 14, 2026 20:35
Vertical gradient text with gradientMode: "clamp" was effectively a no-op
on iOS. Clamp mode only skipped appending the mirror color; the fill was
still a UIColor colorWithPatternImage, which tiles the gradient image in
both axes. Descenders (and any glyph area past the pattern) got a wrapped
tile instead of the clamped edge color, unlike Android's
Shader.TileMode.CLAMP.

Render clamp mode the same way Android does: as one clipped gradient
instead of a tiled pattern.

- RCTAttributedTextUtils.mm: for clamp mode, carry the gradient colors and
  angle as text attributes (RCTTextGradientColors / RCTTextGradientAngle),
  mirroring the existing RCTTextStrokeWidth pattern, and keep a solid
  fallback foreground (the edge color) so measurement and non-custom draw
  paths stay correct. Mirror mode keeps its existing pattern-image path.

- RCTTextLayoutManager.mm: when the gradient attributes are present, replace
  the kCGTextFill pass with a clip-then-gradient pass: accumulate the glyph
  outlines into the clip via kCGTextClip, then draw a single linear gradient
  sized to usedRectForTextContainer (which includes the descent) with
  kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation so
  pixels past the gradient ends clamp to the edge color. This also fixes
  descenders for Gradient/Prism/Pop, not just Toon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCnmaWsXepFBSergf5ca3c
The stroke pass and the fill/gradient pass each duplicated the same
CTM setup + CTFramesetter/CTFrame create/draw/release sequence. Extract
it into RCTDrawAttributedStringInFlippedContext so the coordinate math
lives in one place; the caller keeps ownership of the surrounding
save/restore (the kCGTextClip clip must outlive the call so the gradient
can be drawn into it). Also compute the shared translate once.

No behavior change. The NSLayoutManager default path is intentionally
left separate - the CoreText path sacrifices truncation/ellipsize and
adjustsFontSizeToFit fidelity for glyph-path control, so the two are not
interchangeable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCnmaWsXepFBSergf5ca3c
Two small helpers to reduce duplication and clarify intent:

- RCTNormalizedGradientPointsForAngle (RCTAttributedTextUtils.h): the
  0.5 +/- 0.5*{cos,sin} start/end formula was duplicated verbatim in the
  mirror path (RCTAttributedTextUtils.mm) and the clamp draw path
  (RCTTextLayoutManager.mm). Both now call the shared helper, which
  documents the angle convention (0deg left->right, 90deg top->bottom)
  once and guarantees mirror and clamp point the same direction.

- RCTFirstTextAttributeValue (RCTTextLayoutManager.mm): collapses the
  three near-identical enumerateAttribute blocks (stroke color, gradient
  colors, gradient angle) into single-line, class-checked lookups and
  removes the __block locals.

No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCnmaWsXepFBSergf5ca3c
Applies the actionable findings from a quality pass:

- Name the private draw-attribute keys as shared constants in
  RCTAttributedTextUtils.h (RCTTextStrokeWidth/Color,
  RCTTextGradientColors/Angle) and reference them from both the producer
  and consumer, so the cross-file contract can't silently drift on a typo.
- Collapse the gradient-angle extraction to a single expression
  (floatValue on a nil NSNumber is the desired 0 default).
- Drop the dead `if (attributes != nil)` guard - the sole caller always
  passes a dictionary.
- Create the device RGB color space once via dispatch_once instead of
  allocating/freeing it on every gradient draw.

No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCnmaWsXepFBSergf5ca3c
Per review, the draw-time glyph-clip approach was more machinery than this
edge case needs. Revert the RCTTextLayoutManager draw-path changes (and the
cross-file attribute plumbing) and handle clamp entirely where the mirror
path already lives, in RCTEffectiveForegroundColorFromTextAttributes.

The wrap artifact comes from colorWithPatternImage tiling a pattern shorter
than the text (descenders below the last stop wrap to the opposite-end
color). CAGradientLayer itself already clamps to its edge colors, so for
clamp mode we render the pattern taller than any single line (padded by the
font's line height) and scale the gradient endpoints so it spans only its
intended height at the top - CAGradientLayer holds the edge color below it,
and the taller pattern means a single line never tiles vertically.

Trade-off (accepted): reliably fixes the vertical case that wraps. Text
width is unbounded, so a horizontal gradient that is too short can still
tile; multiline repeats per pattern height rather than clamping globally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCnmaWsXepFBSergf5ca3c
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.

2 participants