diff --git a/example/src/App.tsx b/example/src/App.tsx index 3de7fc5..482f738 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -34,7 +34,8 @@ export default function App() { - + diff --git a/example/src/screens/ShadowsExampleScreen.tsx b/example/src/screens/ShadowsExampleScreen.tsx index 1f86be8..11f0ca4 100644 --- a/example/src/screens/ShadowsExampleScreen.tsx +++ b/example/src/screens/ShadowsExampleScreen.tsx @@ -16,18 +16,14 @@ const ShadowsExampleScreen = () => { const styles = StyleSheet.create(() => ({ view: { - width: 300, - height: 300, - marginTop: 100, + width: 200, + height: 200, alignSelf: 'center', - backgroundColor: 'red', - boxShadow: '5px 5px 5px orange', - transition: [['boxShadow', 'backgroundColor'], 500], - - '&:active': { - backgroundColor: 'blue', - boxShadow: '10px 10px 10px yellow', - }, + marginTop: 100, + border: [15, 'solid', 'green'], + backgroundColor: '#ff0000', + boxShadow: '10px 10px 20px blue', + transition: ['shadowColor', 3000], }, text: { marginTop: 50, @@ -37,7 +33,7 @@ const styles = StyleSheet.create(() => ({ color: 'blue', fontSize: 25, transition: [['textShadowColor', 'textShadowRadius', 'textShadowOffsetWidth', 'textShadowOffsetHeight'], 1000], - textShadow: '0px 2px 5px #ff0000', + textShadow: '0px 2px 10px #ff0000', '&:active': { textShadow: '15px 15px 30px #0044ff', diff --git a/package.json b/package.json index c165fb0..48f7bad 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,8 @@ }, "dependencies": { "color": "^3.1.3", - "react-native-svg": "^12.1.1" + "react-native-svg": "^12.1.1", + "react-native-view-shot": "^3.1.2" }, "devDependencies": { "@commitlint/config-conventional": "^12.1.1", diff --git a/src/Animated.tsx b/src/Animated.tsx index bfe8794..5b55ea3 100644 --- a/src/Animated.tsx +++ b/src/Animated.tsx @@ -15,6 +15,7 @@ import { flattenStyle, getDefaultStyleValue, wrapStyles } from './utils/styles'; import { calc, toCamelCase, toDuration, toEasing } from './utils/values'; import StyleSheet from './StyleSheet'; import { removeInvalidStyles, validStyles } from './utils/valid-styles'; +import BoxShadow, { NATIVELY_SUPPORTED_PLATFORMS } from './components/BoxShadow'; overrideNative(RN.View); overrideNative(RN.Text); @@ -30,8 +31,12 @@ function overrideNative(nativeComp: any) { nativeComp.render = (props: any, ref: any) => { nativeComp.render.displayName = nativeComp.displayName; if ( - !JSON.stringify(props.style)?.includes('&:') && - Object.keys(Object.values(props.style || {}))?.every((k) => validStyles.includes(k)) + Object.keys(Object.values(StyleSheet.flatten(props.style) || {}))?.every( + (k) => + validStyles.includes(k) && + !k.includes('&:') && + !(k.includes('shadow') && !NATIVELY_SUPPORTED_PLATFORMS.includes(RN.Platform.OS)), + ) ) { return ; } @@ -258,7 +263,7 @@ export function createComponent(WrappedComponent: T) return wrapStyles(Object.assign({}, finish, styles)); } - getStyleProps(e: StateCallbackType) { + getStyleProps(e: StateCallbackType, mockShadow = false) { const props = this.props; const propKeys = Object.keys(this.props) as (keyof typeof props)[]; const stylePropKeys = propKeys.filter((key) => key.toLowerCase().startsWith('style')); @@ -268,12 +273,40 @@ export function createComponent(WrappedComponent: T) const styleArr = [props[key]].flat() as Styles[]; const st = styleArr .map((c) => (Array.isArray(c) ? RN.StyleSheet.flatten(c) : c)) - .map((style) => { + .map((style: Styles) => { if (Number.isInteger(style)) { numberStyles.push(style); return; } + if (mockShadow) { + const { + /* eslint-disable @typescript-eslint/no-unused-vars */ + margin, + marginHorizontal, + marginVertical, + marginTop, + marginRight, + marginBottom, + marginLeft, + + position, + left, + right, + bottom, + top, + + flex, + alignSelf, + flexBasis, + flexGrow, + flexShrink, + /* eslint-enable */ + ...remainingStyle + } = style; + style = remainingStyle; + } + let combinedStyles: Styles = Object.assign({}, style, this.getAnimatedStyle(style, e)); combinedStyles = removeInvalidStyles(combinedStyles); @@ -319,13 +352,11 @@ export function createComponent(WrappedComponent: T) } render() { - let { forwardRef, style, as, onFocus, onBlur, ...otherProps } = this.props; - style = StyleSheet.flatten(style); + const { forwardRef, style: rawStyle, as, onFocus, onBlur, ...otherProps } = this.props; + const style = StyleSheet.flatten(rawStyle); const styleKeys = Object.keys(style ?? {}); - // @ts-ignore const before = (style?.['&::before'] || {}) as RN.TextStyle; - // @ts-ignore const after = (style?.['&::after'] || {}) as RN.TextStyle; const beforeContent = before?.content; @@ -337,43 +368,55 @@ export function createComponent(WrappedComponent: T) const AnimatedComponent = this.AnimatedComponent || RN.Animated.createAnimatedComponent(as || WrappedComponent); this.AnimatedComponent = AnimatedComponent; - const content = (e: StateCallbackType) => ( + const needsPressable = styleKeys.some( + (k) => k.includes(':hover') || k.includes(':active') || k.includes(':focus'), + ); + + const mockShadow = + !NATIVELY_SUPPORTED_PLATFORMS.includes(RN.Platform.OS) && + (style.elevation || 0) <= 0 && + styleKeys.some((k) => k.includes('shadowColor')); + + const renderComponent = (e: StateCallbackType) => ( + { + typeof forwardRef === 'function' && forwardRef(r); + this._ref = r; + }} + {...this.getPseudoProps()} + {...this.getStyleProps(e, mockShadow)} + pointerEvents={StyleSheet.flatten(style)?.pointerEvents} + onFocus={(ev: FocusEvent) => { + if (typeof this._ref?.isFocused === 'function') { + this.forceUpdate(); + } + onFocus?.(ev); + }} + onBlur={(ev: FocusEvent) => { + if (typeof this._ref?.isFocused === 'function') { + this.forceUpdate(); + } + onBlur?.(ev); + }} + {...otherProps} + /> + ); + + const renderContent = (e: StateCallbackType) => ( <> {!!BeforeComponent && {beforeContent}} - { - typeof forwardRef === 'function' && forwardRef(r); - this._ref = r; - }} - {...this.getPseudoProps()} - {...this.getStyleProps(e)} - pointerEvents={StyleSheet.flatten(style)?.pointerEvents} - onFocus={(ev: FocusEvent) => { - if (typeof this._ref?.isFocused === 'function') { - this.forceUpdate(); - } - onFocus?.(ev); - }} - onBlur={(ev: FocusEvent) => { - if (typeof this._ref?.isFocused === 'function') { - this.forceUpdate(); - } - onBlur?.(ev); - }} - {...otherProps} - /> + + {!mockShadow && renderComponent(e)} + {mockShadow && {renderComponent(e)}} + {!!AfterComponent && {afterContent}} ); - const needsPressable = styleKeys.some( - (k) => k.includes(':hover') || k.includes(':active') || k.includes(':focus'), - ); - return needsPressable ? ( - {(e: StateCallbackType) => content(e)} + {(e: StateCallbackType) => renderContent(e)} ) : ( - content({ hovered: false, pressed: false, focused: false }) + renderContent({ hovered: false, pressed: false, focused: false }) ); } }; diff --git a/src/components/BoxShadow.tsx b/src/components/BoxShadow.tsx new file mode 100644 index 0000000..ce01b10 --- /dev/null +++ b/src/components/BoxShadow.tsx @@ -0,0 +1,205 @@ +import React, { createRef, Component } from 'react'; +import { ImageBackground, Platform, View, ViewProps, ViewStyle } from 'react-native'; +import ViewShot from 'react-native-view-shot'; +import StyleSheet from '../StyleSheet'; +import { styled } from '../styled-decorator'; + +export const NATIVELY_SUPPORTED_PLATFORMS = ['ios', 'web']; + +interface BoxShadowProps extends ViewProps { + children?: React.ReactNode; +} + +@styled +class BoxShadow extends Component { + styles = styles; + state = { + bgUri: '', + width: 0, + height: 0, + radius: 0, + offset: { top: 0, left: 0 }, + color: '', + opacity: 1, + borderRadius: 0, + outerStyle: {} as ViewStyle, + shadowStyle: {} as ViewStyle, + }; + private _viewRef = createRef(); + + componentDidMount() { + this._recalculate(); + } + + componentDidUpdate(prevProps: BoxShadowProps) { + if (prevProps !== this.props) { + this._recalculate(); + } + } + + private _recalculate() { + this.setState({ bgUri: '' }); + + let { style = {} } = this.props; + style = StyleSheet.flatten(style); + + const { + marginTop, + marginRight, + marginBottom, + marginLeft, + + position, + left, + right, + bottom, + top, + + flex, + alignSelf, + flexBasis, + flexGrow, + flexShrink, + + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + } = style; + + const outerStyle = { + marginTop, + marginRight, + marginBottom, + marginLeft, + + position, + left, + right, + bottom, + top, + + flex, + alignSelf, + flexBasis, + flexGrow, + flexShrink, + }; + + const shadowStyle = { + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + }; + + const width = +(style.width || 0); + const height = +(style.height || 0); + const radius = +(style.shadowRadius || 0) * 8; + const offset = { + top: style.shadowOffset?.height || 0, + left: style.shadowOffset?.width || 0, + }; + const color = style.shadowColor; + const opacity = style.shadowOpacity || 1; + const borderRadius = (radius || 1) / 30; + + this.setState( + { + outerStyle, + shadowStyle, + width, + height, + radius, + offset, + color, + opacity, + borderRadius, + zIndex: style.zIndex || 0, + }, + () => setTimeout(this._capture, 1), + ); + } + + private _capture = async () => { + const dataUri = await this._viewRef.current?.capture?.(); + this.setState({ bgUri: dataUri || '' }); + }; + + render() { + const { bgUri, color } = this.state; + const { children } = this.props; + + return ( + + + + + {children} + {!!bgUri && ( + + )} + + ); + } +} + +const styles = StyleSheet.create((o) => ({ + wrapper: { + position: 'relative', + overflow: 'visible', + pointerEvents: 'box-none', + ...o.state.outerStyle, + }, + shotView: { + position: 'absolute', + opacity: 0, + pointerEvents: 'none', + }, + border: { + width: o.state.width, + height: o.state.height, + backgroundColor: '#fff', + margin: o.state.radius, + ...o.state.shadowStyle, + }, + shadow: { + position: 'absolute', + top: -o.state.radius, + left: -o.state.radius, + width: o.state.width + o.state.radius * 2, + height: o.state.height + o.state.radius * 2, + opacity: o.state.opacity, + margin: [o.state.offset.top, o.state.offset.left], + zIndex: o.state.zIndex - 1, + pointerEvents: 'none', + }, +})); + +export default BoxShadow; diff --git a/src/hooks/useStyles.tsx b/src/hooks/useStyles.tsx index 9679d04..f2d2869 100644 --- a/src/hooks/useStyles.tsx +++ b/src/hooks/useStyles.tsx @@ -7,7 +7,7 @@ import { useTheme } from './useTheme'; import { deepEquals } from '../utils/values'; import { matchMedia } from '../media'; -export const useStyles = (styles: any = {}, ...args: any[]) => { +export const useStyles = (styles: any = {}, args?: any) => { const [builtStyles, setBuiltStyles] = useState({}); const theme = useTheme(); diff --git a/src/preprocessors/box-shadow.ts b/src/preprocessors/box-shadow.ts index 05a634e..80bbdd3 100644 --- a/src/preprocessors/box-shadow.ts +++ b/src/preprocessors/box-shadow.ts @@ -1,16 +1,17 @@ +import { toLength } from '../utils/values'; import validateColor from '../utils/validate-color'; export const boxShadowPreprocessor = (key: string, value: any) => { const valuesArr = typeof value === 'string' ? value.split(' ') : Array.isArray(value) ? value : []; + const width = toLength(valuesArr[0]); + const height = toLength(valuesArr[1]); + if (valuesArr.length) { return { [key]: undefined, - shadowOffset: { - width: valuesArr[0], - height: valuesArr[1], - }, - shadowRadius: validateColor(valuesArr[2]) ? 0 : valuesArr[2], + shadowOffset: { width, height }, + shadowRadius: validateColor(valuesArr[2]) ? 0 : toLength(valuesArr[2]), shadowColor: validateColor(valuesArr[2]) ? valuesArr[2] : valuesArr[3] || 'transparent', shadowOpacity: 1, }; diff --git a/src/preprocessors/text-shadow.ts b/src/preprocessors/text-shadow.ts index 03a303c..e61454e 100644 --- a/src/preprocessors/text-shadow.ts +++ b/src/preprocessors/text-shadow.ts @@ -4,13 +4,13 @@ import validateColor from '../utils/validate-color'; export const textShadowPreprocessor = (key: string, value: any) => { const valuesArr = typeof value === 'string' ? value.split(' ') : Array.isArray(value) ? value : []; + const width = toLength(valuesArr[0]); + const height = toLength(valuesArr[1]); + if (valuesArr.length) { return { [key]: undefined, - textShadowOffset: { - width: toLength(valuesArr[0]), - height: toLength(valuesArr[1]), - }, + textShadowOffset: { width, height }, textShadowRadius: validateColor(valuesArr[2]) ? 0 : toLength(valuesArr[2]), textShadowColor: validateColor(valuesArr[2]) ? valuesArr[2] : valuesArr[3] || 'transparent', }; diff --git a/src/styled-decorator.tsx b/src/styled-decorator.tsx index 2851aad..c4b0382 100644 --- a/src/styled-decorator.tsx +++ b/src/styled-decorator.tsx @@ -14,11 +14,14 @@ export function styled(WrappedComponent: T): T { __styleSheet?: StyleConstructor; private __styles: StyleSheetStyles = {}; - componentDidUpdate(prevProps: any, prevState: any) { - if (!deepEquals(prevProps, this.props) || !deepEquals(prevState, this.state)) { - this.__styles = computeStyles(this); + componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any) { + const prevStyles = Object.assign({}, this.__styles); + const newStyles = computeStyles(this); + if (!deepEquals(prevStyles, newStyles)) { + this.__styles = newStyles; this.forceUpdate(); } + super.componentDidUpdate?.(prevProps, prevState, snapshot); } constructor(...args: any[]) { diff --git a/src/types.ts b/src/types.ts index c649701..88c121b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,8 +72,7 @@ export type StylesProps = { theme: Theme; screen: RN.ScaledSize; window: RN.ScaledSize; -} & { [bp in BreakpointKeys]: Styles } & - Component; +} & { [bp in BreakpointKeys]: Styles } & { [key: string]: any } & Component; type TransformKeys = | 'perspective' @@ -230,6 +229,29 @@ export type AdjustedStyles = { boxShadow?: [number | string, number | string, number | string]; + /** + * + * In the absence of auto property, none is much like CSS's none value. box-none is as if you had applied the CSS class: + * + * .box-none { + * pointer-events: none; + * } + * .box-none * { + * pointer-events: all; + * } + * + * box-only is the equivalent of + * + * .box-only { + * pointer-events: all; + * } + * .box-only * { + * pointer-events: none; + * } + * + * But since pointerEvents does not affect layout/appearance, and we are already deviating from the spec by adding additional modes, + * we opt to not include pointerEvents on style. On some platforms, we would need to implement it as a className anyways. Using style or not is an implementation detail of the platform. + */ pointerEvents?: 'auto' | 'none' | 'box-only' | 'box-none'; color?: string; diff --git a/src/utils/styles.ts b/src/utils/styles.ts index 74d5c4f..6525ad9 100644 --- a/src/utils/styles.ts +++ b/src/utils/styles.ts @@ -1,14 +1,13 @@ import { StyleSheet } from 'react-native'; import type { StyleKeys, Styles } from '../types'; -export const flattenStyle = (style: Styles) => { - let flatStyle = Object.assign({}, StyleSheet.flatten(style)); - flatStyle = flattenTransforms(flatStyle); - flatStyle = flattenShadowOffsets(flatStyle); +export const flattenStyle = (style: Styles = {}) => { + let flatStyle = StyleSheet.flatten(style); + flatStyle = flattenTransforms(flattenShadowOffsets(flatStyle)); return flatStyle; }; -const flattenTransforms = (style: Styles) => { +const flattenTransforms = (style: Styles = {}) => { if (style.transform) { style.transform.forEach((transform) => { const key = Object.keys(transform)[0] as keyof typeof transform; @@ -21,7 +20,7 @@ const flattenTransforms = (style: Styles) => { const flattenShadowOffsets = (style: any): Styles => { const keys = ['shadowOffset', 'textShadowOffset'] as (keyof Styles)[]; - keys.map((key) => { + keys.forEach((key) => { if (style[key]) { style[`${key}Width`] = style[key].width; style[`${key}Height`] = style[key].height; @@ -104,17 +103,17 @@ const TRANSFORM_STYLE_PROPERTIES = [ ]; export const wrapStyles = (styles: Styles) => { - let wrappedStyles = wrapTransforms(styles); - wrappedStyles = wrapShadowOffsets(styles); + let wrappedStyles = StyleSheet.flatten(styles); + wrappedStyles = wrapTransforms(wrapShadowOffsets(styles)); return wrappedStyles; }; // Transforms { translateX: 1 } to { transform: [{ translateX: 1 }]} const wrapTransforms = (style: Styles) => { let wrapped: any = {}; - const styleKeys = Object.keys(style) as (keyof typeof style)[]; + const styleKeys = Object.keys(style) as (keyof Styles)[]; styleKeys.forEach((key) => { - if (TRANSFORM_STYLE_PROPERTIES.indexOf(key) !== -1) { + if (TRANSFORM_STYLE_PROPERTIES.includes(key)) { if (!wrapped.transform) { wrapped.transform = []; } @@ -129,22 +128,19 @@ const wrapTransforms = (style: Styles) => { }; const wrapShadowOffsets = (style: any): Styles => { - style.textShadowOffset = { - width: style.textShadowOffsetWidth, - height: style.textShadowOffsetHeight, - }; - style.shadowOffset = { - width: style.shadowOffsetWidth, - height: style.shadowOffsetHeight, - }; const keys = ['shadowOffset', 'textShadowOffset'] as (keyof Styles)[]; - keys.map((key) => { - if (style[key]) { + keys.forEach((key) => { + if (style[`${key}Width`] || style[`${key}Height`]) { + style[key] = {}; style[key].width = style[`${key}Width`]; style[key].height = style[`${key}Height`]; delete style[`${key}Width`]; delete style[`${key}Height`]; + + if (!style[key].width && !style[key].height) { + delete style[key]; + } } }); return style; diff --git a/yarn.lock b/yarn.lock index f053e39..1fecdd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10373,6 +10373,16 @@ fsevents@^2.1.2: languageName: node linkType: hard +"react-native-view-shot@npm:^3.1.2": + version: 3.1.2 + resolution: "react-native-view-shot@npm:3.1.2" + peerDependencies: + react: "*" + react-native: "*" + checksum: 3a6523eacca6f82477fefe624d7f485689214bd347dae0767853d61029d457905a11b2bc6990750b3ef25b05310d119eecc34021e41649100ddadeff4558347a + languageName: node + linkType: hard + "react-native@npm:^0.64.0": version: 0.64.0 resolution: "react-native@npm:0.64.0" @@ -11370,6 +11380,7 @@ resolve@^2.0.0-next.3: react-native: ^0.64.0 react-native-builder-bob: ^0.18.1 react-native-svg: ^12.1.1 + react-native-view-shot: ^3.1.2 release-it: ^14.6.1 typescript: ^4.2.4 peerDependencies: