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/LICENSE +21 -0
- package/README.md +147 -0
- package/lib/commonjs/Shadow.js +245 -0
- package/lib/commonjs/Shadow.js.map +1 -0
- package/lib/commonjs/index.js +26 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +6 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/Shadow.js +240 -0
- package/lib/module/Shadow.js.map +1 -0
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/Shadow.d.ts +54 -0
- package/lib/typescript/Shadow.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +3 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +79 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/package.json +81 -0
- package/src/Shadow.tsx +260 -0
- package/src/index.ts +7 -0
- package/src/types.ts +78 -0
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
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
|
+
}
|