iOS: draw clamp-mode text gradients as a single clamped fill#10
Draft
andrewkunkel wants to merge 5 commits into
Draft
iOS: draw clamp-mode text gradients as a single clamped fill#10andrewkunkel wants to merge 5 commits into
andrewkunkel wants to merge 5 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 viaUIColor 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, wheregradientMode: "clamp"maps toShader.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, anNSArrayofCGColorRef, andRCTTextGradientAngle), mirroring the existingRCTTextStrokeWidthpattern. 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 thekCGTextFillpass with a clip-then-gradient pass:kCGTextClip+ the same CTM setup +CTFrameDrawaccumulate the glyph outlines into the clip.CGContextDrawLinearGradientis drawn, sized tousedRectForTextContainer(which includes the descent, guaranteeing descenders are covered), withkCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation— the iOS equivalent ofShader.TileMode.CLAMP, so pixels past the gradient ends take the edge color instead of wrapping.y.0with no stroke, so the fill lands exactly wheredrawGlyphsForGlyphRange:would have placed it).Changelog:
[IOS] [FIXED] - Fix
gradientMode: "clamp"for text so vertical gradients clamp (instead of tiling) and descenders render the edge colorTest Plan:
gradientMode: "clamp"renders as a single top-to-bottom gradient with no vertical tiling/wrapping.g,y,p,q,j) render the clamped edge color rather than a re-tiled band, for Gradient/Prism/Pop/Toon.🤖 Generated with Claude Code
https://claude.ai/code/session_01DCnmaWsXepFBSergf5ca3c
Generated by Claude Code