Skip to content

fix(iOS): hold first/last gradient stop colors for out-of-range stops (CLAMP parity with Android)#16

Draft
andrewkunkel wants to merge 1 commit into
mainfrom
claude/text-gradient-clamp-mode-kpjohk
Draft

fix(iOS): hold first/last gradient stop colors for out-of-range stops (CLAMP parity with Android)#16
andrewkunkel wants to merge 1 commit into
mainfrom
claude/text-gradient-clamp-mode-kpjohk

Conversation

@andrewkunkel

@andrewkunkel andrewkunkel commented Jul 16, 2026

Copy link
Copy Markdown

Summary:

View background gradients (experimental_backgroundImage) rendered differently on iOS and Android whenever a color-stop position fell outside the [0, 1] gradient box (e.g. linear-gradient(to right, red -50%, blue 150%), percentages beyond 0–100%, or px stops that extend past the box).

  • Android passes the raw fixed-up stop positions (which may be < 0 or > 1 — only a monotonic lower-bound clamp is applied) straight into android.graphics.LinearGradient / RadialGradient with Shader.TileMode.CLAMP. CLAMP holds the first color before the first stop and the last color after the last stop, so a gradient whose stops extend past the box shows only its visible slice.
  • iOS (RCTGradientUtils.mm, getColors:andLocations:) clamped each stop position independently into [0, 1] before handing locations to CAGradientLayer. This collapsed out-of-range stops onto the box edge and distorted the gradient instead of holding the terminal colors.

CAGradientLayer.locations must lie within [0, 1] (it clamps out-of-range values internally), so the fix cannot simply pass raw positions through — it emulates TileMode.CLAMP by resampling the fixed-up stop list into the [0, 1] window:

  • When the whole box lies past the first/last stop → hold that terminal color as a solid fill.
  • Otherwise → keep the interior stops and synthesize boundary stops at 0 and/or 1 with the interpolated color, reusing the existing straight-sRGB RCTInterpolateColorInRange path (the same interpolation the transition-hint code already uses, matching Android's default sRGB shader interpolation).

Stops already within [0, 1] pass through unchanged, so the common case is preserved byte-for-byte. The change is contained to RCTGradientUtils.mm — no call-site or public-signature changes, and the linear "squish" correction / radial axis math are untouched (their axis endpoints already map to positions 0 and 1).

Changelog:

[IOS] [FIXED] - Gradient color stops positioned outside [0, 1] now hold the first/last stop colors, matching Android's Shader.TileMode.CLAMP behavior

Test Plan:

Manual visual verification on a local iOS build against Android (there is no automated screenshot coverage for gradients in the repo today):

  • Render a View with an out-of-range gradient, e.g. linear-gradient(to right, red -50%, green 50%, blue 150%) (partial slice) and linear-gradient(to right, red 120%, blue 150%) (solid hold), plus a radial equivalent.
  • Confirm iOS now matches Android: terminal colors are held / only the visible slice is shown, rather than the previous edge-collapse distortion.
  • Regression: existing in-range gradients are unchanged (the clip returns the input untouched when all stops are within [0, 1]).

Note: this is iOS Objective-C++; it was reviewed statically but not compiled or run in the authoring environment (no Xcode/CocoaPods there). A local iOS build with a side-by-side visual check against Android is the recommended pre-merge verification.

🤖 Generated with Claude Code

… (CLAMP parity with Android)

iOS clamped each color-stop position independently into [0, 1] before
handing locations to CAGradientLayer, so stops positioned outside the
gradient box collapsed onto the edge and distorted the gradient. Android
passes raw positions to android.graphics.LinearGradient/RadialGradient
with Shader.TileMode.CLAMP, which holds the first/last colors and shows
only the visible slice of a gradient whose stops extend past the box.

Emulate CLAMP on iOS by resampling out-of-range stops back into [0, 1]:
hold the terminal color as a solid fill when the whole box is past the
first/last stop, and interpolate boundary colors (reusing the existing
straight-sRGB RCTInterpolateColorInRange path) otherwise. In-range stops
pass through unchanged, so the common case is preserved byte-for-byte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nxs5hyXkj8fZ6TUPzv37cA
@andrewkunkel
andrewkunkel force-pushed the claude/text-gradient-clamp-mode-kpjohk branch from e045964 to 51bcaf1 Compare July 16, 2026 17:13
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