Skip to content

Commit

Permalink
Refactor the logic to measure the children
Browse files Browse the repository at this point in the history
  • Loading branch information
Henrique Ramos committed Feb 13, 2025
1 parent 3ab4706 commit 0e4b3c3
Show file tree
Hide file tree
Showing 5 changed files with 136 additions and 95 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-native-skeleton-placeholder",
"version": "6.0.0-beta.2",
"version": "6.0.0-beta.3",
"description": "SkeletonPlaceholder is a React Native library to easily create an amazing loading effect.",
"main": "lib/skeleton-placeholder.js",
"scripts": {
Expand Down
99 changes: 99 additions & 0 deletions src/measure.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, {useRef} from 'react';
import {LayoutRectangle, View, ViewProps, ViewStyle} from 'react-native';

import {RectType} from './types';

type MeasureProps = {
defaultBackgroundColor?: string;
onComplete: (container: LayoutRectangle, rects: RectType[]) => void;
};

const Measure = ({
children,
onComplete,
defaultBackgroundColor,
}: React.PropsWithChildren<MeasureProps>) => {
const rects = useRef<RectType[]>([]);
const totalElements = useRef(0);
const measuredElements = useRef(0);
const containerRef = useRef<View>(null);

const renderRecursive = React.useCallback(
(
node: React.ReactNode,
callback: (rect: RectType) => void,
currentIndex = 0,
): React.ReactNode => {
return React.Children.map(
node,
(child: React.ReactElement<ViewProps & {ref: React.Ref<View>}>) => {
if (child.props.children) {
return React.cloneElement(
child,
child.props,
renderRecursive(
child.props.children,
callback,
currentIndex + React.Children.count(node),
),
);
}

/**
* When the element doesn't have children, we can count it as a total element
* We don't care about parent elements, only the direct children
*/

totalElements.current = totalElements.current + 1;

return React.cloneElement(child, {
...(child.props ?? {}),
style: {
...((child.props.style ?? {}) as ViewStyle),
backgroundColor: defaultBackgroundColor,
},
onLayout: (event) => {
/**
* Mark this element as measured
*/
measuredElements.current = measuredElements.current + 1;

callback({
x: event.nativeEvent.layout.x,
y: event.nativeEvent.layout.y,
width: event.nativeEvent.layout.width,
height: event.nativeEvent.layout.height,
...((child.props.style ?? {}) as ViewStyle),
});
},
});
},
);
},
[],
);

/**
* We'll render the children and measure them
* When all the elements are measured, we'll measure the container
*/
return (
<View ref={containerRef}>
{renderRecursive(children, ({x, y, width, height, ...style}) => {
rects.current.push({x, y, width, height, ...style});
if (measuredElements.current === totalElements.current) {
containerRef.current?.measure(
(containerX, containerY, containerWidth, containerHeight) => {
onComplete(
{x: containerX, y: containerY, width: containerWidth, height: containerHeight},
rects.current,
);
},
);
}
})}
</View>
);
};

export default Measure;
109 changes: 27 additions & 82 deletions src/skeleton-placeholder.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {useRef, useState} from 'react';
import {View, ViewProps} from 'react-native';
import React, {useState} from 'react';
import {LayoutRectangle} from 'react-native';
import Animated, {
useAnimatedProps,
useSharedValue,
Expand All @@ -8,8 +8,9 @@ import Animated, {
} from 'react-native-reanimated';
import {Defs, LinearGradient, Mask, Rect, Stop, Svg} from 'react-native-svg';

import Measure from './measure';
import SvgRenderer from './svg-rendered';
import {Measurements} from './types';
import {RectType} from './types';
import flatten from './utils/flatten';

const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
Expand Down Expand Up @@ -49,11 +50,12 @@ const SkeletonPlaceholder = ({
angle = 0,
children,
}: React.PropsWithChildren<SkeletonPlaceholderProps>) => {
const childRefs = useRef<{ref: View; styles: any}[]>([]);

const [containerSize, setContainerSize] = useState({
width: 0,
height: 0,
const [content, setContent] = useState<{
container?: LayoutRectangle;
rects: RectType[];
}>({
container: undefined,
rects: [],
});

const x1 = useSharedValue(-200);
Expand All @@ -69,86 +71,35 @@ const SkeletonPlaceholder = ({
x2: `${x2.value}%`,
}));

const measureChild = (index: number): Measurements => {
const node = childRefs.current[index];
let measurements: Measurements = {
x: 0,
y: 0,
pageX: 0,
pageY: 0,
width: 0,
height: 0,
styles: {},
};
if (!node) return measurements;

node.ref?.measure?.((x, y, width, height, pageX, pageY) => {
measurements = {
x,
y,
width,
height,
pageX,
pageY,
styles: node.styles,
};
});

return measurements;
};

const flattenedChildren = flatten(children as any);
const childrenStyles = flattenedChildren.map((child) => child.props.style);
/**
* TODO: find a better way to handle this;
* Every time a child changes, the hash changes and the component "re-renders".
* Then, the calculations are done again and the animation is reset.
* We need to know when the children styles change to recalculate the content
*/
const hash = JSON.stringify(childrenStyles);

React.useEffect(() => {
return () => {
childRefs.current = [];
setContainerSize({width: 0, height: 0});
setContent({container: undefined, rects: []});
};
}, [hash]);

const renderRecursive = React.useCallback((node: React.ReactNode): React.ReactNode => {
return React.Children.map(
node,
(child: React.ReactElement<ViewProps & {ref: React.Ref<View>}>) => {
if (child.props.children)
return React.cloneElement(child, child.props, renderRecursive(child.props.children));

return React.cloneElement(child, {
...(child.props ?? {}),
ref: (ref: View) => {
childRefs.current.push({ref, styles: child?.props?.style});
},
});
},
);
}, []);

const MemoizedRender = React.useMemo(() => {
if (containerSize.width === 0 || containerSize.height === 0) {
const Render = React.useCallback(() => {
if (!content.container) {
return (
<View
// eslint-disable-next-line react-native/no-inline-styles
style={{position: 'absolute', zIndex: -1, opacity: 0}}
onLayout={(event) => {
setContainerSize({
width: event.nativeEvent.layout.width,
height: event.nativeEvent.layout.height,
});
<Measure
defaultBackgroundColor={backgroundColor}
onComplete={(container, rects) => {
setContent({container, rects});
}}>
{renderRecursive(children as any)}
</View>
{children}
</Measure>
);
}

return (
<Svg width={containerSize.width} height={containerSize.height}>
<Svg width={content.container?.width} height={content.container?.height}>
<Defs>
<AnimatedLinearGradient
id="grad"
Expand All @@ -158,38 +109,32 @@ const SkeletonPlaceholder = ({
<Stop offset="50%" stopColor={highlightColor} />
<Stop offset="100%" stopColor={backgroundColor} />
</AnimatedLinearGradient>

<Mask id="shapeMask">
<SvgRenderer
defaultBorderRadius={borderRadius}
measurements={childRefs.current.map((_, index) => measureChild(index))}
/>
<SvgRenderer defaultBorderRadius={borderRadius} rects={content.rects} />
</Mask>
</Defs>
<Rect
x="0"
y="0"
width={containerSize.width}
height={containerSize.height}
width={content.container?.width}
height={content.container?.height}
fill="url(#grad)"
mask="url(#shapeMask)"
/>
</Svg>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
containerSize.width,
containerSize.height,
content.container,
animatedProps,
angle,
backgroundColor,
highlightColor,
borderRadius,
childRefs,
renderRecursive,
content.rects,
]);

return MemoizedRender;
return <Render />;
};

export default SkeletonPlaceholder;
14 changes: 7 additions & 7 deletions src/svg-rendered.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import React from 'react';
import {Rect} from 'react-native-svg';

import {Measurements} from './types';
import {RectType} from './types';

type SvgRendererProps = {
measurements: Measurements[];
rects: RectType[];
defaultBorderRadius: number;
};
const SvgRenderer = ({measurements, defaultBorderRadius = 0}: SvgRendererProps) => {
const SvgRenderer = ({rects, defaultBorderRadius = 0}: SvgRendererProps) => {
return (
<>
{measurements.map(({pageX, pageY, width, height, styles}, index) => {
{rects.map(({x, y, width, height, ...styles}, index) => {
return (
<Rect
key={index}
fillOpacity={1}
x={pageX}
y={pageY}
x={x}
y={y}
width={width}
height={height}
rx={Number(styles?.borderRadius) || defaultBorderRadius}
fill="white"
fill="white" // it's important to make the svg mask to work
stroke={styles?.borderColor}
strokeWidth={styles?.borderWidth}
/>
Expand Down
7 changes: 2 additions & 5 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import {ViewStyle} from 'react-native';

export type Measurements = {
export type RectType = {
x: number;
y: number;
pageX: number;
pageY: number;
width: number;
height: number;
styles: ViewStyle;
};
} & ViewStyle;

0 comments on commit 0e4b3c3

Please sign in to comment.