react-native-skia-box-shadow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Shadow.tsx ADDED
@@ -0,0 +1,260 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import {
3
+ StyleSheet,
4
+ View,
5
+ PixelRatio,
6
+ type LayoutChangeEvent,
7
+ } from 'react-native';
8
+ import {
9
+ Canvas,
10
+ Group,
11
+ RoundedRect,
12
+ Rect,
13
+ Circle,
14
+ Path,
15
+ Blur,
16
+ Skia,
17
+ } from '@shopify/react-native-skia';
18
+
19
+ import type {
20
+ ShadowProps,
21
+ ShadowParams,
22
+ ShadowShape,
23
+ ShadowFillStyle,
24
+ } from './types';
25
+
26
+ // ── Defaults (mirrors ShadowDefaults.kt) ───────────────────────
27
+ const DEFAULTS = {
28
+ fillStyle: { kind: 'color', color: 'rgba(0,0,0,0.10)' } as ShadowFillStyle,
29
+ blurRadius: 24,
30
+ spread: 4,
31
+ offsetX: 0,
32
+ offsetY: 0,
33
+ } as const;
34
+
35
+ const pixelRatio = PixelRatio.get();
36
+
37
+ // ── Single shadow layer renderer ────────────────────────────────
38
+ const ShadowLayer: React.FC<{
39
+ params: ShadowParams;
40
+ width: number;
41
+ height: number;
42
+ defaultShape: ShadowShape;
43
+ }> = ({ params, width, height, defaultShape }) => {
44
+ const {
45
+ fillStyle = DEFAULTS.fillStyle,
46
+ blurRadius = DEFAULTS.blurRadius,
47
+ spread = DEFAULTS.spread,
48
+ offsetX = DEFAULTS.offsetX,
49
+ offsetY = DEFAULTS.offsetY,
50
+ shape: shapeOverride,
51
+ } = params;
52
+
53
+ const shape = shapeOverride ?? defaultShape;
54
+
55
+ // ── Paint ─────────────────────────────────────────────────────
56
+ const paint = useMemo(() => {
57
+ const p = Skia.Paint();
58
+ if (fillStyle.kind === 'color') {
59
+ p.setColor(Skia.Color(fillStyle.color));
60
+ } else {
61
+ p.setShader(fillStyle.factory(width, height));
62
+ }
63
+ return p;
64
+ }, [fillStyle, width, height]);
65
+
66
+ // ── Shadow sizing (spread) ────────────────────────────────────
67
+ const shadowWidth = width + spread * 2;
68
+ const shadowHeight = height + spread * 2;
69
+ const scaleX = width > 0 ? shadowWidth / width : 1;
70
+ const scaleY = height > 0 ? shadowHeight / height : 1;
71
+
72
+ // ── Blur (adjusted for pixel density) ─────────────────────────
73
+ const adjustedBlur = blurRadius / pixelRatio;
74
+ const blurChild = adjustedBlur > 0 ? <Blur blur={adjustedBlur} /> : null;
75
+
76
+ // ── Shape element (with Blur as child) ────────────────────────
77
+ const shapeElement = useMemo(() => {
78
+ switch (shape.kind) {
79
+ case 'roundedRect':
80
+ return (
81
+ <RoundedRect
82
+ x={0}
83
+ y={0}
84
+ width={width}
85
+ height={height}
86
+ r={shape.radius}
87
+ paint={paint}
88
+ >
89
+ {blurChild}
90
+ </RoundedRect>
91
+ );
92
+
93
+ case 'circle': {
94
+ const r = Math.min(width, height) / 2;
95
+ return (
96
+ <Circle cx={width / 2} cy={height / 2} r={r} paint={paint}>
97
+ {blurChild}
98
+ </Circle>
99
+ );
100
+ }
101
+
102
+ case 'path':
103
+ return (
104
+ <Path path={shape.svgPath} paint={paint}>
105
+ {blurChild}
106
+ </Path>
107
+ );
108
+
109
+ case 'rect':
110
+ default:
111
+ return (
112
+ <Rect x={0} y={0} width={width} height={height} paint={paint}>
113
+ {blurChild}
114
+ </Rect>
115
+ );
116
+ }
117
+ }, [shape, width, height, paint, blurChild]);
118
+
119
+ return (
120
+ <Group transform={[{ translateX: offsetX }, { translateY: offsetY }]}>
121
+ <Group
122
+ transform={[{ scaleX }, { scaleY }]}
123
+ origin={{ x: width / 2, y: height / 2 }}
124
+ >
125
+ {shapeElement}
126
+ </Group>
127
+ </Group>
128
+ );
129
+ };
130
+
131
+ /**
132
+ * `<Shadow>` — CSS-style box shadows for React Native.
133
+ *
134
+ * Renders one or more blurred, colored shadows behind `children`.
135
+ * Supports blur, spread, offset, custom colors/shaders, and
136
+ * arbitrary shapes (rect, roundedRect, circle, SVG path).
137
+ *
138
+ * Powered by `@shopify/react-native-skia`.
139
+ *
140
+ * @example
141
+ * ```tsx
142
+ * import { Shadow } from 'react-native-skia-box-shadow';
143
+ *
144
+ * <Shadow
145
+ * shadows={{
146
+ * fillStyle: { kind: 'color', color: 'rgba(0,0,0,0.15)' },
147
+ * blurRadius: 20,
148
+ * offsetY: 4,
149
+ * }}
150
+ * shape={{ kind: 'roundedRect', radius: 16 }}
151
+ * >
152
+ * <View style={styles.card}>
153
+ * <Text>Card with shadow</Text>
154
+ * </View>
155
+ * </Shadow>
156
+ * ```
157
+ *
158
+ * @example
159
+ * ```tsx
160
+ * // Multiple shadow layers
161
+ * <Shadow
162
+ * shadows={[
163
+ * { blurRadius: 4, offsetY: 2, fillStyle: { kind: 'color', color: 'rgba(0,0,0,0.08)' } },
164
+ * { blurRadius: 16, offsetY: 8, fillStyle: { kind: 'color', color: 'rgba(0,0,0,0.12)' } },
165
+ * ]}
166
+ * shape={{ kind: 'roundedRect', radius: 12 }}
167
+ * >
168
+ * {children}
169
+ * </Shadow>
170
+ * ```
171
+ */
172
+ const Shadow: React.FC<ShadowProps> = ({
173
+ shadows,
174
+ shape = { kind: 'rect' },
175
+ width: _width,
176
+ height: _height,
177
+ style,
178
+ children,
179
+ }) => {
180
+ const [layout, setLayout] = useState<{
181
+ width: number;
182
+ height: number;
183
+ } | null>(null);
184
+
185
+ const onLayout = (e: LayoutChangeEvent) => {
186
+ const { width, height } = e.nativeEvent.layout;
187
+ setLayout({ width, height });
188
+ };
189
+
190
+ const width = _width ?? layout?.width ?? 0;
191
+ const height = _height ?? layout?.height ?? 0;
192
+
193
+ const shadowList = Array.isArray(shadows) ? shadows : [shadows];
194
+
195
+ // ── Compute canvas padding ────────────────────────────────────
196
+ // The canvas must be large enough to contain blurred + spread +
197
+ // offset shadows without clipping.
198
+ const canvasPadding = useMemo(() => {
199
+ let maxExtent = 0;
200
+ for (const s of shadowList) {
201
+ const blur = s.blurRadius ?? DEFAULTS.blurRadius;
202
+ const spread = s.spread ?? DEFAULTS.spread;
203
+ const ox = Math.abs(s.offsetX ?? DEFAULTS.offsetX);
204
+ const oy = Math.abs(s.offsetY ?? DEFAULTS.offsetY);
205
+ const extent = blur * 3 + spread + Math.max(ox, oy);
206
+ if (extent > maxExtent) maxExtent = extent;
207
+ }
208
+ return Math.ceil(maxExtent);
209
+ }, [shadowList]);
210
+
211
+ const hasSize = width > 0 && height > 0;
212
+
213
+ return (
214
+ <View style={[styles.container, style]} onLayout={onLayout}>
215
+ {hasSize && (
216
+ <Canvas
217
+ style={[
218
+ styles.canvas,
219
+ {
220
+ top: -canvasPadding,
221
+ left: -canvasPadding,
222
+ width: width + canvasPadding * 2,
223
+ height: height + canvasPadding * 2,
224
+ },
225
+ ]}
226
+ >
227
+ <Group
228
+ transform={[
229
+ { translateX: canvasPadding },
230
+ { translateY: canvasPadding },
231
+ ]}
232
+ >
233
+ {shadowList.map((params, idx) => (
234
+ <ShadowLayer
235
+ key={idx}
236
+ params={params}
237
+ width={width}
238
+ height={height}
239
+ defaultShape={shape}
240
+ />
241
+ ))}
242
+ </Group>
243
+ </Canvas>
244
+ )}
245
+
246
+ {children}
247
+ </View>
248
+ );
249
+ };
250
+
251
+ const styles = StyleSheet.create({
252
+ container: {},
253
+ canvas: {
254
+ position: 'absolute',
255
+ pointerEvents: 'none',
256
+ },
257
+ });
258
+
259
+ export default Shadow;
260
+ export { Shadow, DEFAULTS as ShadowDefaults };
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { default as Shadow, Shadow as ShadowView, ShadowDefaults } from './Shadow';
2
+ export type {
3
+ ShadowProps,
4
+ ShadowParams,
5
+ ShadowShape,
6
+ ShadowFillStyle,
7
+ } from './types';
package/src/types.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ViewStyle, StyleProp } from 'react-native';
3
+
4
+ /**
5
+ * Fill style for shadow — solid color or a Skia shader factory.
6
+ *
7
+ * @example
8
+ * // Solid color
9
+ * { kind: 'color', color: 'rgba(0,0,0,0.12)' }
10
+ *
11
+ * // Linear gradient
12
+ * {
13
+ * kind: 'shader',
14
+ * factory: (w, h) => Skia.Shader.MakeLinearGradient(
15
+ * { x: 0, y: 0 }, { x: w, y: h },
16
+ * [Skia.Color('#6366F1'), Skia.Color('#EC4899')],
17
+ * null, 0,
18
+ * ),
19
+ * }
20
+ */
21
+ export type ShadowFillStyle =
22
+ | { kind: 'color'; color: string }
23
+ | {
24
+ kind: 'shader';
25
+ factory: (
26
+ width: number,
27
+ height: number,
28
+ ) => import('@shopify/react-native-skia').SkShader;
29
+ };
30
+
31
+ /**
32
+ * Single shadow layer descriptor.
33
+ * All numeric values are in device-independent points.
34
+ */
35
+ export interface ShadowParams {
36
+ /** Fill style: solid color string or shader factory. Default: black @ 10% */
37
+ fillStyle?: ShadowFillStyle;
38
+ /** Gaussian blur radius (in points, Figma-compatible). Default: 24 */
39
+ blurRadius?: number;
40
+ /** Expands the shadow outline beyond element bounds. Default: 4 */
41
+ spread?: number;
42
+ /** Horizontal offset. Default: 0 */
43
+ offsetX?: number;
44
+ /** Vertical offset. Default: 0 */
45
+ offsetY?: number;
46
+ /**
47
+ * Shape override for this specific shadow layer.
48
+ * When undefined, inherits `shape` from the parent `<Shadow>`.
49
+ */
50
+ shape?: ShadowShape;
51
+ }
52
+
53
+ /**
54
+ * Supported shadow shapes.
55
+ */
56
+ export type ShadowShape =
57
+ | { kind: 'rect' }
58
+ | { kind: 'roundedRect'; radius: number }
59
+ | { kind: 'circle' }
60
+ | { kind: 'path'; svgPath: string };
61
+
62
+ /**
63
+ * Props for the `<Shadow>` component.
64
+ */
65
+ export interface ShadowProps {
66
+ /** One or more shadow layers. */
67
+ shadows: ShadowParams | ShadowParams[];
68
+ /** Default shape applied to shadows that don't specify their own. Default: rect */
69
+ shape?: ShadowShape;
70
+ /** Explicit component width. If omitted, measured via onLayout. */
71
+ width?: number;
72
+ /** Explicit component height. If omitted, measured via onLayout. */
73
+ height?: number;
74
+ /** RN style for the outer container. */
75
+ style?: StyleProp<ViewStyle>;
76
+ /** Content rendered above the shadow. */
77
+ children?: ReactNode;
78
+ }