UNPKG

react-native-shadow-2

Version:

Cross-platform shadow for React Native. Improved version of the abandoned react-native-shadow package

420 lines (419 loc) 24.3 kB
// eslint-disable-next-line @typescript-eslint/consistent-type-imports import React, { Children, useMemo, useState } from 'react'; import { I18nManager, StyleSheet, View } from 'react-native'; import { Defs, LinearGradient, Mask, Path, Rect, Stop, Svg } from 'react-native-svg'; import { colord } from 'colord'; import { additional, cornersArray, divDps, generateGradientIdSuffix, objFromKeys, P, R, radialGradient, rtlAbsoluteFillObject, rtlScaleX, scale, sumDps, } from './utils'; /** Package Semver. Used on the [Snack](https://snack.expo.dev/@srbrahma/react-native-shadow-2-sandbox). */ export const version = '7.1.1'; // For better memoization and performance. const emptyObj = {}; const defaultOffset = [0, 0]; export function Shadow(props) { return props.disabled ? <DisabledShadow {...props}/> : <ShadowInner {...props}/>; } function ShadowInner(props) { var _a, _b, _c, _d, _e; /** getConstants().isRTL instead of just isRTL due to Web https://github.com/necolas/react-native-web/issues/2350#issuecomment-1193642853 */ const isRTL = I18nManager.getConstants().isRTL; const [childLayout, setChildLayout] = useState(); const [idSuffix] = useState(generateGradientIdSuffix); const { sides, corners, startColor: startColorProp, endColor: endColorProp, distance: distanceProp, style: styleProp, safeRender, stretch, /** Defaults to true if offset is defined, else defaults to false */ paintInside = props.offset ? true : false, offset = defaultOffset, children, containerStyle, shadowViewProps, childrenViewProps, containerViewProps, } = props; /** `s` is a shortcut for `style` I am using in another lib of mine (react-native-gev). While currently no one uses it besides me, * I believe it may come to be a popular pattern eventually :) */ const childProps = Children.count(children) === 1 ? (_a = Children.only(children).props) !== null && _a !== void 0 ? _a : emptyObj : emptyObj; const childStyleStr = useMemo(() => (childProps.style ? JSON.stringify(childProps.style) : null), [childProps.style]); const childSStr = useMemo(() => (childProps.s ? JSON.stringify(childProps.s) : null), [childProps.s]); /** Child's style. */ const cStyle = useMemo(() => { const cStyle = StyleSheet.flatten([ childStyleStr && JSON.parse(childStyleStr), childSStr && JSON.parse(childSStr), ]); if (typeof cStyle.width === 'number') cStyle.width = R(cStyle.width); if (typeof cStyle.height === 'number') cStyle.height = R(cStyle.height); return cStyle; }, [childSStr, childStyleStr]); /** Child's Radii. */ const cRadii = useMemo(() => { var _a, _b, _c, _d, _e, _f, _g, _h; return { topStart: (_b = (_a = cStyle.borderTopStartRadius) !== null && _a !== void 0 ? _a : cStyle.borderTopLeftRadius) !== null && _b !== void 0 ? _b : cStyle.borderRadius, topEnd: (_d = (_c = cStyle.borderTopEndRadius) !== null && _c !== void 0 ? _c : cStyle.borderTopRightRadius) !== null && _d !== void 0 ? _d : cStyle.borderRadius, bottomStart: (_f = (_e = cStyle.borderBottomStartRadius) !== null && _e !== void 0 ? _e : cStyle.borderBottomLeftRadius) !== null && _f !== void 0 ? _f : cStyle.borderRadius, bottomEnd: (_h = (_g = cStyle.borderBottomEndRadius) !== null && _g !== void 0 ? _g : cStyle.borderBottomRightRadius) !== null && _h !== void 0 ? _h : cStyle.borderRadius, }; }, [cStyle]); const styleStr = useMemo(() => (styleProp ? JSON.stringify(styleProp) : null), [styleProp]); /** Flattened style. */ const { style, sRadii } = useMemo(() => { var _a, _b, _c, _d, _e, _f, _g, _h; const style = styleStr ? StyleSheet.flatten(JSON.parse(styleStr)) : {}; if (typeof style.width === 'number') style.width = R(style.width); if (typeof style.height === 'number') style.height = R(style.height); return { style, sRadii: { topStart: (_b = (_a = style.borderTopStartRadius) !== null && _a !== void 0 ? _a : style.borderTopLeftRadius) !== null && _b !== void 0 ? _b : style.borderRadius, topEnd: (_d = (_c = style.borderTopEndRadius) !== null && _c !== void 0 ? _c : style.borderTopRightRadius) !== null && _d !== void 0 ? _d : style.borderRadius, bottomStart: (_f = (_e = style.borderBottomStartRadius) !== null && _e !== void 0 ? _e : style.borderBottomLeftRadius) !== null && _f !== void 0 ? _f : style.borderRadius, bottomEnd: (_h = (_g = style.borderBottomEndRadius) !== null && _g !== void 0 ? _g : style.borderBottomRightRadius) !== null && _h !== void 0 ? _h : style.borderRadius, }, }; }, [styleStr]); const styleWidth = (_b = style.width) !== null && _b !== void 0 ? _b : cStyle.width; const width = (_c = styleWidth !== null && styleWidth !== void 0 ? styleWidth : childLayout === null || childLayout === void 0 ? void 0 : childLayout.width) !== null && _c !== void 0 ? _c : '100%'; // '100%' sometimes will lead to gaps. Child's size don't lie. const styleHeight = (_d = style.height) !== null && _d !== void 0 ? _d : cStyle.height; const height = (_e = styleHeight !== null && styleHeight !== void 0 ? styleHeight : childLayout === null || childLayout === void 0 ? void 0 : childLayout.height) !== null && _e !== void 0 ? _e : '100%'; const radii = useMemo(() => { var _a, _b, _c, _d; return sanitizeRadii({ width, height, radii: { topStart: (_a = sRadii.topStart) !== null && _a !== void 0 ? _a : cRadii.topStart, topEnd: (_b = sRadii.topEnd) !== null && _b !== void 0 ? _b : cRadii.topEnd, bottomStart: (_c = sRadii.bottomStart) !== null && _c !== void 0 ? _c : cRadii.bottomStart, bottomEnd: (_d = sRadii.bottomEnd) !== null && _d !== void 0 ? _d : cRadii.bottomEnd, }, }); }, [ width, height, sRadii.topStart, sRadii.topEnd, sRadii.bottomStart, sRadii.bottomEnd, cRadii.topStart, cRadii.topEnd, cRadii.bottomStart, cRadii.bottomEnd, ]); const { topStart, topEnd, bottomStart, bottomEnd } = radii; const shadow = useMemo(() => { var _a, _b, _c, _d, _e, _f, _g, _h; return getShadow({ topStart, topEnd, bottomStart, bottomEnd, width, height, isRTL, distanceProp, startColorProp, endColorProp, paintInside, safeRender, activeSides: { bottom: (_a = sides === null || sides === void 0 ? void 0 : sides.bottom) !== null && _a !== void 0 ? _a : true, top: (_b = sides === null || sides === void 0 ? void 0 : sides.top) !== null && _b !== void 0 ? _b : true, start: (_c = sides === null || sides === void 0 ? void 0 : sides.start) !== null && _c !== void 0 ? _c : true, end: (_d = sides === null || sides === void 0 ? void 0 : sides.end) !== null && _d !== void 0 ? _d : true, }, activeCorners: { topStart: (_e = corners === null || corners === void 0 ? void 0 : corners.topStart) !== null && _e !== void 0 ? _e : true, topEnd: (_f = corners === null || corners === void 0 ? void 0 : corners.topEnd) !== null && _f !== void 0 ? _f : true, bottomStart: (_g = corners === null || corners === void 0 ? void 0 : corners.bottomStart) !== null && _g !== void 0 ? _g : true, bottomEnd: (_h = corners === null || corners === void 0 ? void 0 : corners.bottomEnd) !== null && _h !== void 0 ? _h : true, }, idSuffix, }); }, [ width, height, distanceProp, startColorProp, endColorProp, topStart, topEnd, bottomStart, bottomEnd, paintInside, sides === null || sides === void 0 ? void 0 : sides.bottom, sides === null || sides === void 0 ? void 0 : sides.top, sides === null || sides === void 0 ? void 0 : sides.start, sides === null || sides === void 0 ? void 0 : sides.end, corners === null || corners === void 0 ? void 0 : corners.topStart, corners === null || corners === void 0 ? void 0 : corners.topEnd, corners === null || corners === void 0 ? void 0 : corners.bottomStart, corners === null || corners === void 0 ? void 0 : corners.bottomEnd, safeRender, isRTL, idSuffix, ]); // Not yet sure if we should memo this. return getResult({ shadow, children, stretch, offset, radii, containerStyle, style, shadowViewProps, childrenViewProps, containerViewProps, styleWidth, styleHeight, childLayout, setChildLayout, }); } /** We make some effort for this to be likely memoized */ function sanitizeRadii(props) { /** Round and zero negative radius values */ let radiiSanitized = objFromKeys(cornersArray, (k) => { var _a; return R(Math.max((_a = props.radii[k]) !== null && _a !== void 0 ? _a : 0, 0)); }); if (typeof props.width === 'number' && typeof props.height === 'number') { // https://css-tricks.com/what-happens-when-border-radii-overlap/ // Note that the tutorial above doesn't mention the specification of minRatio < 1 but it's required as said on spec and will malfunction without it. const minRatio = Math.min(divDps(props.width, sumDps(radiiSanitized.topStart, radiiSanitized.topEnd)), divDps(props.height, sumDps(radiiSanitized.topEnd, radiiSanitized.bottomEnd)), divDps(props.width, sumDps(radiiSanitized.bottomStart, radiiSanitized.bottomEnd)), divDps(props.height, sumDps(radiiSanitized.topStart, radiiSanitized.bottomStart))); if (minRatio < 1) // We ensure to use the .floor instead of the R else we could have the following case: // A topStart=3, topEnd=3 and width=5. This would cause a pixel overlap between those 2 corners. // The .floor ensures that the radii sum will be below the adjacent border length. radiiSanitized = objFromKeys(cornersArray, (k) => Math.floor(P(radiiSanitized[k]) * minRatio) / scale); } return radiiSanitized; } /** The SVG parts. */ // We default the props here for a micro improvement in performance. endColorProp default value was the main reason. function getShadow({ safeRender, width, height, isRTL, distanceProp = 10, startColorProp = '#00000020', endColorProp, topStart, topEnd, bottomStart, bottomEnd, activeSides, activeCorners, paintInside, idSuffix, }) { // Skip if using safeRender and we still don't have the exact sizes, if we are still on the first render using the relative sizes. if (safeRender && (typeof width === 'string' || typeof height === 'string')) return null; const distance = R(Math.max(distanceProp, 0)); // Min val as 0 // Quick return if not going to show up anything if (!distance && !paintInside) return null; const distanceWithAdditional = distance + additional; /** Will (+ additional), only if its value isn't '100%'. [*4] */ const widthWithAdditional = typeof width === 'string' ? width : width + additional; /** Will (+ additional), only if its value isn't '100%'. [*4] */ const heightWithAdditional = typeof height === 'string' ? height : height + additional; const startColord = colord(startColorProp); const endColord = endColorProp ? colord(endColorProp) : startColord.alpha(0); // [*1]: Seems that SVG in web accepts opacity in hex color, but in mobile gradient doesn't. // So we remove the opacity from the color, and only apply the opacity in stopOpacity, so in web // it isn't applied twice. const startColorWoOpacity = startColord.alpha(1).toHex(); const endColorWoOpacity = endColord.alpha(1).toHex(); const startColorOpacity = startColord.alpha(); const endColorOpacity = endColord.alpha(); // Fragment wasn't working for some reason, so, using array. const linearGradient = [ // [*1] In mobile, it's required for the alpha to be set in opacity prop to work. // In web, smaller offsets needs to come before, so offset={0} definition comes first. <Stop offset={0} stopColor={startColorWoOpacity} stopOpacity={startColorOpacity} key='1'/>, <Stop offset={1} stopColor={endColorWoOpacity} stopOpacity={endColorOpacity} key='2'/>, ]; const radialGradient2 = (p) => radialGradient(Object.assign(Object.assign({}, p), { startColorWoOpacity, startColorOpacity, endColorWoOpacity, endColorOpacity, paintInside })); const cornerShadowRadius = { topStartShadow: sumDps(topStart, distance), topEndShadow: sumDps(topEnd, distance), bottomStartShadow: sumDps(bottomStart, distance), bottomEndShadow: sumDps(bottomEnd, distance), }; const { topStartShadow, topEndShadow, bottomStartShadow, bottomEndShadow } = cornerShadowRadius; /* Skip sides if we don't have a distance. */ const sides = distance > 0 && (<> {/* Skip side if adjacents corners use its size already */} {activeSides.start && (typeof height === 'number' ? height > topStart + bottomStart : true) && (<Svg width={distanceWithAdditional} height={heightWithAdditional} style={{ position: 'absolute', start: -distance, top: topStart }}> <Defs> <LinearGradient id={`start.${idSuffix}`} x1={isRTL ? '0' : '1'} y1='0' x2={isRTL ? '1' : '0'} y2='0'> {linearGradient} </LinearGradient> </Defs> {/* I was using a Mask here to remove part of each side (same size as now, sum of related corners), but, just moving the rectangle outside its viewbox is already a mask!! -> svg overflow is cutten away. <- */} <Rect width={distance} height={height} fill={`url(#start.${idSuffix})`} y={-sumDps(topStart, bottomStart)}/> </Svg>)} {activeSides.end && (typeof height === 'number' ? height > topEnd + bottomEnd : true) && (<Svg width={distanceWithAdditional} height={heightWithAdditional} style={{ position: 'absolute', start: width, top: topEnd }}> <Defs> <LinearGradient id={`end.${idSuffix}`} x1={isRTL ? '1' : '0'} y1='0' x2={isRTL ? '0' : '1'} y2='0'> {linearGradient} </LinearGradient> </Defs> <Rect width={distance} height={height} fill={`url(#end.${idSuffix})`} y={-sumDps(topEnd, bottomEnd)}/> </Svg>)} {activeSides.top && (typeof width === 'number' ? width > topStart + topEnd : true) && (<Svg width={widthWithAdditional} height={distanceWithAdditional} style={Object.assign({ position: 'absolute', top: -distance, start: topStart + (isRTL ? 1 : 0) }, (isRTL && rtlScaleX))}> <Defs> <LinearGradient id={`top.${idSuffix}`} x1='0' y1='1' x2='0' y2='0'> {linearGradient} </LinearGradient> </Defs> <Rect width={width} height={distance} fill={`url(#top.${idSuffix})`} x={-sumDps(topStart, topEnd)}/> </Svg>)} {activeSides.bottom && (typeof width === 'number' ? width > bottomStart + bottomEnd : true) && (<Svg width={widthWithAdditional} height={distanceWithAdditional} style={Object.assign({ position: 'absolute', top: height, start: bottomStart + (isRTL ? 1 : 0) }, (isRTL && rtlScaleX))}> <Defs> <LinearGradient id={`bottom.${idSuffix}`} x1='0' y1='0' x2='0' y2='1'> {linearGradient} </LinearGradient> </Defs> <Rect width={width} height={distance} fill={`url(#bottom.${idSuffix})`} x={-sumDps(bottomStart, bottomEnd)}/> </Svg>)} </>); /* The anchor for the svgs path is the top left point in the corner square. The starting point is the clockwise external arc init point. */ /* Checking topLeftShadowEtc > 0 due to https://github.com/ftzi/react-native-shadow-2/issues/47. */ const corners = (<> {activeCorners.topStart && topStartShadow > 0 && (<Svg width={topStartShadow + additional} height={topStartShadow + additional} style={{ position: 'absolute', top: -distance, start: -distance }}> <Defs> {radialGradient2({ id: `topStart.${idSuffix}`, top: true, left: !isRTL, radius: topStart, shadowRadius: topStartShadow, })} </Defs> <Rect fill={`url(#topStart.${idSuffix})`} width={topStartShadow} height={topStartShadow}/> </Svg>)} {activeCorners.topEnd && topEndShadow > 0 && (<Svg width={topEndShadow + additional} height={topEndShadow + additional} style={{ position: 'absolute', top: -distance, start: width, transform: [{ translateX: isRTL ? topEnd : -topEnd }], }}> <Defs> {radialGradient2({ id: `topEnd.${idSuffix}`, top: true, left: isRTL, radius: topEnd, shadowRadius: topEndShadow, })} </Defs> <Rect fill={`url(#topEnd.${idSuffix})`} width={topEndShadow} height={topEndShadow}/> </Svg>)} {activeCorners.bottomStart && bottomStartShadow > 0 && (<Svg width={bottomStartShadow + additional} height={bottomStartShadow + additional} style={{ position: 'absolute', top: height, start: -distance, transform: [{ translateY: -bottomStart }], }}> <Defs> {radialGradient2({ id: `bottomStart.${idSuffix}`, top: false, left: !isRTL, radius: bottomStart, shadowRadius: bottomStartShadow, })} </Defs> <Rect fill={`url(#bottomStart.${idSuffix})`} width={bottomStartShadow} height={bottomStartShadow}/> </Svg>)} {activeCorners.bottomEnd && bottomEndShadow > 0 && (<Svg width={bottomEndShadow + additional} height={bottomEndShadow + additional} style={{ position: 'absolute', top: height, start: width, transform: [{ translateX: isRTL ? bottomEnd : -bottomEnd }, { translateY: -bottomEnd }], }}> <Defs> {radialGradient2({ id: `bottomEnd.${idSuffix}`, top: false, left: isRTL, radius: bottomEnd, shadowRadius: bottomEndShadow, })} </Defs> <Rect fill={`url(#bottomEnd.${idSuffix})`} width={bottomEndShadow} height={bottomEndShadow}/> </Svg>)} </>); /** * Paint the inner area, so we can offset it. * [*2]: I tried redrawing the inner corner arc, but there would always be a small gap between the external shadows * and this internal shadow along the curve. So, instead we dont specify the inner arc on the corners when * paintBelow, but just use a square inner corner. And here we will just mask those squares in each corner. */ const inner = paintInside && (<Svg width={widthWithAdditional} height={heightWithAdditional} style={Object.assign({ position: 'absolute' }, (isRTL && rtlScaleX))}> {typeof width === 'number' && typeof height === 'number' ? ( // Maybe due to how react-native-svg handles masks in iOS, the paintInside would have gaps: https://github.com/ftzi/react-native-shadow-2/issues/36 // We use Path as workaround to it. <Path fill={startColorWoOpacity} fillOpacity={startColorOpacity} d={`M0,${topStart} v${height - bottomStart - topStart} h${bottomStart} v${bottomStart} h${width - bottomStart - bottomEnd} v${-bottomEnd} h${bottomEnd} v${-height + bottomEnd + topEnd} h${-topEnd} v${-topEnd} h${-width + topStart + topEnd} v${topStart} Z`}/>) : (<> <Defs> <Mask id={`maskInside.${idSuffix}`}> {/* Paint all white, then black on border external areas to erase them */} <Rect width={width} height={height} fill='#fff'/> {/* Remove the corners */} <Rect width={topStart} height={topStart} fill='#000'/> <Rect width={topEnd} height={topEnd} x={width} transform={`translate(${-topEnd}, 0)`} fill='#000'/> <Rect width={bottomStart} height={bottomStart} y={height} transform={`translate(0, ${-bottomStart})`} fill='#000'/> <Rect width={bottomEnd} height={bottomEnd} x={width} y={height} transform={`translate(${-bottomEnd}, ${-bottomEnd})`} fill='#000'/> </Mask> </Defs> <Rect width={width} height={height} mask={`url(#maskInside.${idSuffix})`} fill={startColorWoOpacity} fillOpacity={startColorOpacity}/> </>)} </Svg>); return (<> {sides} {corners} {inner} </>); } function getResult(props) { // const isWidthPrecise = styleWidth; var _a; return ( // pointerEvents: https://github.com/ftzi/react-native-shadow-2/issues/24 <View style={props.containerStyle} pointerEvents='box-none' {...props.containerViewProps}> <View pointerEvents='none' {...props.shadowViewProps} style={[ rtlAbsoluteFillObject, (_a = props.shadowViewProps) === null || _a === void 0 ? void 0 : _a.style, { start: props.offset[0], top: props.offset[1] }, ]}> {props.shadow} </View> <View pointerEvents='box-none' style={[ { // We are defining here the radii so when using radius props it also affects the backgroundColor and Pressable ripples are properly contained. // Note that topStart/etc has priority over topLeft/etc. We use topLeft so the user may overwrite it with topLeft or topStart styles. borderTopLeftRadius: props.radii.topStart, borderTopRightRadius: props.radii.topEnd, borderBottomLeftRadius: props.radii.bottomStart, borderBottomRightRadius: props.radii.bottomEnd, alignSelf: 'flex-start', // Default to 'flex-start' instead of 'stretch'. }, props.style, Object.assign({}, (props.stretch && { alignSelf: 'stretch' })), ]} onLayout={(e) => { var _a, _b; // For some reason, conditionally setting the onLayout wasn't working on condition change. // [web] [*3]: the width/height we get here is already rounded by RN, even if the real size according to the browser // inspector is decimal. It will round up if (>= .5), else, down. const eventLayout = e.nativeEvent.layout; // Change layout state if the style width/height is undefined or 'x%', or the sizes in pixels are different. if ((typeof props.styleWidth !== 'number' && (((_a = props.childLayout) === null || _a === void 0 ? void 0 : _a.width) === undefined || P(eventLayout.width) !== P(props.childLayout.width))) || (typeof props.styleHeight !== 'number' && (((_b = props.childLayout) === null || _b === void 0 ? void 0 : _b.height) === undefined || P(eventLayout.height) !== P(props.childLayout.height)))) props.setChildLayout({ width: eventLayout.width, height: eventLayout.height }); }} {...props.childrenViewProps}> {props.children} </View> </View>); } function DisabledShadow(props) { return (<View style={props.containerStyle} pointerEvents='box-none' {...props.containerViewProps}> <View pointerEvents='box-none' {...props.childrenViewProps} style={[props.style, Object.assign({}, (props.stretch && { alignSelf: 'stretch' }))]}> {props.children} </View> </View>); }