Skip to content

Commit 5aaee64

Browse files
committed
feat(view): render conic gradients natively
1 parent b22a3ac commit 5aaee64

6 files changed

Lines changed: 216 additions & 7 deletions

File tree

‎packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
#import <React/RCTBackgroundImageUtils.h>
2121
#import <React/RCTBorderDrawing.h>
2222
#import <React/RCTBoxShadow.h>
23+
#import <React/RCTConicGradient.h>
2324
#import <React/RCTConversions.h>
2425
#import <React/RCTLinearGradient.h>
2526
#import <React/RCTLocalizedString.h>
@@ -1315,9 +1316,12 @@ - (void)invalidateLayer
13151316
backgroundSize:backgroundSize
13161317
backgroundRepeat:backgroundRepeat];
13171318

1318-
CALayer *gradientLayer;
1319+
CALayer *gradientLayer = nil;
13191320

1320-
if (std::holds_alternative<LinearGradient>(backgroundImage)) {
1321+
if (std::holds_alternative<ConicGradient>(backgroundImage)) {
1322+
const auto &conicGradient = std::get<ConicGradient>(backgroundImage);
1323+
gradientLayer = [RCTConicGradient gradientLayerWithSize:backgroundImageSize gradient:conicGradient];
1324+
} else if (std::holds_alternative<LinearGradient>(backgroundImage)) {
13211325
const auto &linearGradient = std::get<LinearGradient>(backgroundImage);
13221326
gradientLayer = [RCTLinearGradient gradientLayerWithSize:backgroundImageSize gradient:linearGradient];
13231327
} else if (std::holds_alternative<RadialGradient>(backgroundImage)) {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#import <QuartzCore/QuartzCore.h>
9+
#import <react/renderer/graphics/ConicGradient.h>
10+
11+
NS_ASSUME_NONNULL_BEGIN
12+
13+
@interface RCTConicGradient : NSObject
14+
15+
+ (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const facebook::react::ConicGradient &)gradient;
16+
17+
@end
18+
19+
NS_ASSUME_NONNULL_END
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#import "RCTConicGradient.h"
9+
10+
#import "RCTGradientUtils.h"
11+
12+
#import <cmath>
13+
14+
using namespace facebook::react;
15+
16+
@implementation RCTConicGradient
17+
18+
+ (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const ConicGradient &)gradient
19+
{
20+
CAGradientLayer *gradientLayer = [CAGradientLayer layer];
21+
gradientLayer.type = kCAGradientLayerConic;
22+
23+
CGPoint centerPoint = CGPointMake(size.width / 2.0, size.height / 2.0);
24+
if (gradient.position.top.has_value()) {
25+
centerPoint.y = gradient.position.top->resolve(static_cast<float>(size.height));
26+
} else if (gradient.position.bottom.has_value()) {
27+
centerPoint.y = size.height - gradient.position.bottom->resolve(static_cast<float>(size.height));
28+
}
29+
if (gradient.position.left.has_value()) {
30+
centerPoint.x = gradient.position.left->resolve(static_cast<float>(size.width));
31+
} else if (gradient.position.right.has_value()) {
32+
centerPoint.x = size.width - gradient.position.right->resolve(static_cast<float>(size.width));
33+
}
34+
35+
CGPoint normalizedCenter = CGPointMake(centerPoint.x / size.width, centerPoint.y / size.height);
36+
CGFloat radians = gradient.from * M_PI / 180.0;
37+
gradientLayer.startPoint = normalizedCenter;
38+
gradientLayer.endPoint = CGPointMake(normalizedCenter.x + std::sin(radians), normalizedCenter.y - std::cos(radians));
39+
40+
const auto colorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops gradientLineLength:1.0];
41+
NSMutableArray<id> *colors = [NSMutableArray array];
42+
NSMutableArray<NSNumber *> *locations = [NSMutableArray array];
43+
[RCTGradientUtils getColors:colors andLocations:locations fromColorStops:colorStops];
44+
45+
gradientLayer.frame = CGRectMake(0.0f, 0.0f, size.width, size.height);
46+
gradientLayer.colors = colors;
47+
gradientLayer.locations = locations;
48+
return gradientLayer;
49+
}
50+
51+
@end

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ import com.facebook.react.bridge.ReadableType
1515
/**
1616
* Represents a single layer of a background image, typically containing a gradient.
1717
*
18-
* This class encapsulates gradient definitions (linear or radial) that can be applied as background
19-
* layers to React Native views. It provides parsing from React Native bridge data and shader
20-
* generation for rendering.
18+
* This class encapsulates gradient definitions that can be applied as background layers to React
19+
* Native views. It provides parsing from React Native bridge data and shader generation for
20+
* rendering.
2121
*
22+
* @see ConicGradient
2223
* @see LinearGradient
2324
* @see RadialGradient
2425
*/
@@ -33,8 +34,8 @@ public class BackgroundImageLayer() {
3334
/**
3435
* Parses a ReadableMap into a BackgroundImageLayer.
3536
*
36-
* The map should contain gradient configuration including a "type" key specifying either
37-
* "linear-gradient" or "radial-gradient".
37+
* The map should contain gradient configuration including a "type" key specifying a supported
38+
* gradient type.
3839
*
3940
* @param gradientMap The map containing gradient configuration
4041
* @param context Android context for resource resolution
@@ -54,6 +55,7 @@ public class BackgroundImageLayer() {
5455
}
5556

5657
return when (gradientMap.getString("type")) {
58+
"conic-gradient" -> ConicGradient.parse(gradientMap, context)
5759
"linear-gradient" -> LinearGradient.parse(gradientMap, context)
5860
"radial-gradient" -> RadialGradient.parse(gradientMap, context)
5961
else -> null
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.uimanager.style
9+
10+
import android.content.Context
11+
import android.graphics.Matrix
12+
import android.graphics.Shader
13+
import android.graphics.SweepGradient
14+
import com.facebook.react.bridge.ColorPropConverter
15+
import com.facebook.react.bridge.ReadableMap
16+
import com.facebook.react.bridge.ReadableType
17+
import com.facebook.react.uimanager.LengthPercentage
18+
import com.facebook.react.uimanager.LengthPercentageType
19+
import com.facebook.react.uimanager.PixelUtil.dpToPx
20+
21+
internal class ConicGradient(
22+
val from: Float,
23+
val position: RadialGradient.Position,
24+
val colorStops: List<ColorStop>,
25+
) : Gradient {
26+
companion object {
27+
fun parse(gradientMap: ReadableMap, context: Context): Gradient? {
28+
if (!gradientMap.hasKey("from") || !gradientMap.hasKey("position")) {
29+
return null
30+
}
31+
val from = gradientMap.getDouble("from").toFloat()
32+
val positionMap = gradientMap.getMap("position") ?: return null
33+
var top: LengthPercentage? = null
34+
var left: LengthPercentage? = null
35+
var right: LengthPercentage? = null
36+
var bottom: LengthPercentage? = null
37+
38+
if (positionMap.hasKey("top")) {
39+
top = LengthPercentage.setFromDynamic(positionMap.getDynamic("top"))
40+
} else if (positionMap.hasKey("bottom")) {
41+
bottom = LengthPercentage.setFromDynamic(positionMap.getDynamic("bottom"))
42+
}
43+
if (positionMap.hasKey("left")) {
44+
left = LengthPercentage.setFromDynamic(positionMap.getDynamic("left"))
45+
} else if (positionMap.hasKey("right")) {
46+
right = LengthPercentage.setFromDynamic(positionMap.getDynamic("right"))
47+
}
48+
49+
val colorStopsArray = gradientMap.getArray("colorStops") ?: return null
50+
val colorStops = ArrayList<ColorStop>(colorStopsArray.size())
51+
for (i in 0 until colorStopsArray.size()) {
52+
val colorStop = colorStopsArray.getMap(i) ?: continue
53+
val color: Int? =
54+
when {
55+
!colorStop.hasKey("color") || colorStop.isNull("color") -> null
56+
colorStop.getType("color") == ReadableType.Map ->
57+
ColorPropConverter.getColor(colorStop.getMap("color"), context)
58+
else -> colorStop.getInt("color")
59+
}
60+
val stopPosition = LengthPercentage.setFromDynamic(colorStop.getDynamic("position"))
61+
colorStops.add(ColorStop(color, stopPosition))
62+
}
63+
64+
if (colorStops.size < 2) {
65+
return null
66+
}
67+
return ConicGradient(from, RadialGradient.Position(top, left, right, bottom), colorStops)
68+
}
69+
}
70+
71+
override fun getShader(width: Float, height: Float): Shader {
72+
var centerX = width / 2f
73+
var centerY = height / 2f
74+
position.top?.let {
75+
centerY = if (it.type == LengthPercentageType.PERCENT) it.resolve(height) else it.resolve(height).dpToPx()
76+
}
77+
position.bottom?.let {
78+
centerY = if (it.type == LengthPercentageType.PERCENT) height - it.resolve(height) else height - it.resolve(height).dpToPx()
79+
}
80+
position.left?.let {
81+
centerX = if (it.type == LengthPercentageType.PERCENT) it.resolve(width) else it.resolve(width).dpToPx()
82+
}
83+
position.right?.let {
84+
centerX = if (it.type == LengthPercentageType.PERCENT) width - it.resolve(width) else width - it.resolve(width).dpToPx()
85+
}
86+
87+
val finalStops = ColorStopUtils.getFixedColorStops(colorStops, 1f)
88+
val colors = IntArray(finalStops.size)
89+
val positions = FloatArray(finalStops.size)
90+
finalStops.forEachIndexed { index, colorStop ->
91+
colors[index] = colorStop.color ?: 0
92+
positions[index] = colorStop.position ?: 0f
93+
}
94+
95+
val shader = SweepGradient(centerX, centerY, colors, positions)
96+
shader.setLocalMatrix(Matrix().apply { setRotate(from - 90f, centerX, centerY) })
97+
return shader
98+
}
99+
}

‎packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,40 @@ exports.examples = [
180180
);
181181
},
182182
},
183+
{
184+
title: 'Conic Gradient',
185+
description:
186+
'Conic gradients rotate color stops clockwise around a configurable center.',
187+
name: 'conic',
188+
render(): React.Node {
189+
return (
190+
<View>
191+
<View style={styles.row}>
192+
<View style={styles.col}>
193+
<Text>Quadrants</Text>
194+
<BackgroundImageBox
195+
style={{
196+
backgroundImage:
197+
'conic-gradient(from 0deg, #ff6b6b 0deg 90deg, #4ecdc4 90deg 180deg, #45b7d1 180deg 270deg, #f9ca24 270deg 360deg)',
198+
}}
199+
testID="background-image-conic-quadrants"
200+
/>
201+
</View>
202+
<View style={styles.col}>
203+
<Text>Rotated and off-center</Text>
204+
<BackgroundImageBox
205+
style={{
206+
backgroundImage:
207+
'conic-gradient(from 45deg at 30% 70%, #ff6b6b, #4ecdc4, #ff6b6b)',
208+
}}
209+
testID="background-image-conic-position"
210+
/>
211+
</View>
212+
</View>
213+
</View>
214+
);
215+
},
216+
},
183217
{
184218
title: 'Gradient with Background Repeat',
185219
name: 'repeat',

0 commit comments

Comments
 (0)