UNPKG

@baby-journey/rn-segmented-progress-bar

Version:
263 lines (250 loc) 10 kB
"use strict"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { Animated, Easing, StyleSheet, View } from 'react-native'; import Svg, { Circle, G, TSpan } from 'react-native-svg'; import { getArcEndCoordinates, getPathValues } from './helpers'; import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; const IndicatorCircle = Animated.createAnimatedComponent(Circle); const ProgressCircle = Animated.createAnimatedComponent(Circle); const max = 100; const duration = 1200; const progressDelay = 500; const INDICATOR_FONT = { textAnchor: 'middle', fontSize: 18 }; /** * Computes the declarative strokeDashoffset for a segment. * Returns an Animated.AnimatedInterpolation when the segment has progress, * or a static number when it doesn't. */ const computeStrokeDashoffset = (animatedVal, target, circumference, numSegments, gap) => { if (target <= 0) return circumference; const gapAdjust = numSegments * target * gap / 100; const targetOffset = circumference * (1 - target / 100) + gapAdjust; const thresholdV = gapAdjust > 0 ? gapAdjust * 100 / circumference : 0; // Gap consumes all progress — segment stays hidden if (thresholdV >= target) return circumference; // With gap: dead zone at start where offset stays at circumference if (thresholdV > 0) { return animatedVal.interpolate({ inputRange: [0, thresholdV, target], outputRange: [circumference, circumference, targetOffset], extrapolate: 'clamp' }); } // No gap: simple linear interpolation return animatedVal.interpolate({ inputRange: [0, target], outputRange: [circumference, targetOffset], extrapolate: 'clamp' }); }; const RNSegmentedProgressBar = (props, ref) => { const { radius, strokeWidth = 10, baseColor = '#ffede1', progressColor = '#F39E93', segments = 3, segmentsGap = 0, indicator, centerComponent } = props; const animatedValue = useRef(new Animated.Value(0)).current; const progressAnimatedValues = useRef(new Array(segments).fill(null).map(() => new Animated.Value(0))).current; const indicatorCircleRef = useRef(null); const tSpanRef = useRef(null); // Per-segment target values — drives both render-time interpolation and animation const [segmentTargets, setSegmentTargets] = useState(() => new Array(segments).fill(0)); // Overall progress stored in ref (only needed inside animation, not for render) const activeProgressRef = useRef(0); // Pending animation config — bridges run() and the post-render effect const pendingAnimationRef = useRef(null); const indicatorSegmentsGap = indicator?.radius ?? 0; const halfCircle = radius + strokeWidth + indicatorSegmentsGap; const circleCircumference = 2 * Math.PI * radius; const rotation = -90 + 180 * (segmentsGap / 2 / radius) / Math.PI; const getProgressValues = useCallback(progress => getPathValues(progress, max, segments), [segments]); const getMeanSegmentsGap = useCallback(progress => { const pathValues = getProgressValues(progress); const activeSegments = pathValues.filter(val => val > 0).length; return progress / (activeSegments || 1) * segments * segmentsGap / 100; }, [getProgressValues, segments, segmentsGap]); // Clean up on unmount useEffect(() => { return () => { animatedValue.stopAnimation(); animatedValue.removeAllListeners(); progressAnimatedValues.forEach(v => { v.stopAnimation(); v.removeAllListeners(); }); }; }, [animatedValue, progressAnimatedValues]); // Start animations AFTER React has re-rendered with updated segmentTargets. // This guarantees progressTrack useMemo has correct interpolations before // any animated values start ticking. useEffect(() => { const pending = pendingAnimationRef.current; if (!pending) return; pendingAnimationRef.current = null; const { progress, targets } = pending; const progressAnimations = Animated.sequence(progressAnimatedValues.map((animVal, index) => Animated.timing(animVal, { toValue: targets[index] ?? 0, duration: duration * (targets[index] ?? 0) / max, delay: index === 0 ? progressDelay : 0, useNativeDriver: false, easing: Easing.linear }))); if (indicator?.show) { const percentageAnim = Animated.timing(animatedValue, { toValue: progress, duration: duration * progress / max, delay: progressDelay, useNativeDriver: false, easing: Easing.linear }); Animated.parallel([progressAnimations, percentageAnim]).start(); } else { progressAnimations.start(); } }, [segmentTargets, animatedValue, indicator?.show, progressAnimatedValues]); const run = useCallback(({ progress }) => { // Stop ongoing animations and clear all listeners animatedValue.stopAnimation(); animatedValue.removeAllListeners(); animatedValue.setValue(0); progressAnimatedValues.forEach(v => { v.stopAnimation(); v.removeAllListeners(); v.setValue(0); }); const targets = getProgressValues(progress); activeProgressRef.current = progress; // Set up indicator static properties once (not per-frame) if (indicator?.show && indicatorCircleRef.current && tSpanRef.current) { const calculatedProgress = `${Math.round(progress)}%`; // @ts-ignore – setNativeProps exists on native ref indicatorCircleRef.current.setNativeProps({ r: indicator.radius || 0, strokeWidth: indicator.strokeWidth || 0 }); // @ts-ignore – setNativeProps exists on native ref tSpanRef.current.setNativeProps({ children: calculatedProgress, font: INDICATOR_FONT }); } // Set up indicator position listener (trig-based, can't use interpolate) if (indicator?.show) { const meanGap = getMeanSegmentsGap(progress); animatedValue.addListener(v => { const val = Math.min(v.value, progress); const paintedLength = circleCircumference * val / 100; const adjustedLength = paintedLength - meanGap; if (adjustedLength <= 0) return; const { x: cx, y: cy } = getArcEndCoordinates(radius, adjustedLength, halfCircle, halfCircle, rotation); if (indicatorCircleRef.current) { // @ts-ignore – setNativeProps exists on native ref indicatorCircleRef.current.setNativeProps({ cx, cy }); } if (tSpanRef.current) { // @ts-ignore – setNativeProps exists on native ref tSpanRef.current.setNativeProps({ dx: cx, dy: cy + 5 }); } }); } // Store pending config and trigger re-render with new targets. // Animations start in a useEffect AFTER React has re-rendered, // ensuring interpolations in progressTrack are set up correctly. pendingAnimationRef.current = { progress, targets }; setSegmentTargets(targets); }, [animatedValue, circleCircumference, getMeanSegmentsGap, getProgressValues, halfCircle, indicator?.show, indicator?.radius, indicator?.strokeWidth, progressAnimatedValues, radius, rotation]); // Memoize base track circles (static, never animate) const baseTrack = useMemo(() => { const baseStrokeDashoffset = circleCircumference - circleCircumference / segments + segmentsGap; return new Array(segments).fill(null).map((_, key) => /*#__PURE__*/_jsx(Circle, { cx: halfCircle, cy: halfCircle, r: radius, stroke: baseColor, rotation: rotation + key * 360 / segments, origin: `${halfCircle}, ${halfCircle}`, strokeWidth: strokeWidth, strokeDasharray: circleCircumference, strokeDashoffset: baseStrokeDashoffset, strokeLinecap: "round" }, key)); }, [baseColor, circleCircumference, halfCircle, radius, rotation, segments, segmentsGap, strokeWidth]); // Memoize progress overlay circles with declarative interpolated strokeDashoffset const progressTrack = useMemo(() => { return progressAnimatedValues.map((animVal, key) => { const target = segmentTargets[key] ?? 0; const strokeDashoffset = computeStrokeDashoffset(animVal, target, circleCircumference, segments, segmentsGap); return /*#__PURE__*/_jsx(ProgressCircle, { stroke: progressColor, cx: halfCircle, cy: halfCircle, r: radius, origin: `${halfCircle}, ${halfCircle}`, strokeWidth: strokeWidth, strokeDasharray: circleCircumference, strokeDashoffset: strokeDashoffset, rotation: rotation + key * 360 / segments, strokeLinecap: "round" }, key); }); }, [circleCircumference, halfCircle, progressAnimatedValues, progressColor, radius, rotation, segmentTargets, segments, segmentsGap, strokeWidth]); useImperativeHandle(ref, () => ({ run }), [run]); return /*#__PURE__*/_jsxs(Svg, { viewBox: `0 0 ${halfCircle * 2} ${halfCircle * 2}`, width: '100%', fill: "none", height: radius * 2, children: [centerComponent && /*#__PURE__*/_jsx(View, { style: styles.centerComponent, children: centerComponent }), /*#__PURE__*/_jsxs(G, { children: [baseTrack, progressTrack, indicator?.show === true && /*#__PURE__*/_jsxs(_Fragment, { children: [/*#__PURE__*/_jsx(IndicatorCircle, { stroke: progressColor, ref: indicatorCircleRef, fill: "white" }), /*#__PURE__*/_jsx(TSpan, { stroke: progressColor, fill: progressColor, ref: tSpanRef })] })] })] }); }; export default /*#__PURE__*/memo(/*#__PURE__*/forwardRef(RNSegmentedProgressBar)); const styles = StyleSheet.create({ centerComponent: { height: '100%', justifyContent: 'center', alignItems: 'center' } }); //# sourceMappingURL=index.js.map