@baby-journey/rn-segmented-progress-bar
Version:
Animated circular progress bar with segments
268 lines (255 loc) • 11.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _react = require("react");
var _reactNative = require("react-native");
var _reactNativeSvg = _interopRequireWildcard(require("react-native-svg"));
var _helpers = require("./helpers");
var _jsxRuntime = require("react/jsx-runtime");
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
const IndicatorCircle = _reactNative.Animated.createAnimatedComponent(_reactNativeSvg.Circle);
const ProgressCircle = _reactNative.Animated.createAnimatedComponent(_reactNativeSvg.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 = (0, _react.useRef)(new _reactNative.Animated.Value(0)).current;
const progressAnimatedValues = (0, _react.useRef)(new Array(segments).fill(null).map(() => new _reactNative.Animated.Value(0))).current;
const indicatorCircleRef = (0, _react.useRef)(null);
const tSpanRef = (0, _react.useRef)(null);
// Per-segment target values — drives both render-time interpolation and animation
const [segmentTargets, setSegmentTargets] = (0, _react.useState)(() => new Array(segments).fill(0));
// Overall progress stored in ref (only needed inside animation, not for render)
const activeProgressRef = (0, _react.useRef)(0);
// Pending animation config — bridges run() and the post-render effect
const pendingAnimationRef = (0, _react.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 = (0, _react.useCallback)(progress => (0, _helpers.getPathValues)(progress, max, segments), [segments]);
const getMeanSegmentsGap = (0, _react.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
(0, _react.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.
(0, _react.useEffect)(() => {
const pending = pendingAnimationRef.current;
if (!pending) return;
pendingAnimationRef.current = null;
const {
progress,
targets
} = pending;
const progressAnimations = _reactNative.Animated.sequence(progressAnimatedValues.map((animVal, index) => _reactNative.Animated.timing(animVal, {
toValue: targets[index] ?? 0,
duration: duration * (targets[index] ?? 0) / max,
delay: index === 0 ? progressDelay : 0,
useNativeDriver: false,
easing: _reactNative.Easing.linear
})));
if (indicator?.show) {
const percentageAnim = _reactNative.Animated.timing(animatedValue, {
toValue: progress,
duration: duration * progress / max,
delay: progressDelay,
useNativeDriver: false,
easing: _reactNative.Easing.linear
});
_reactNative.Animated.parallel([progressAnimations, percentageAnim]).start();
} else {
progressAnimations.start();
}
}, [segmentTargets, animatedValue, indicator?.show, progressAnimatedValues]);
const run = (0, _react.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
} = (0, _helpers.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 = (0, _react.useMemo)(() => {
const baseStrokeDashoffset = circleCircumference - circleCircumference / segments + segmentsGap;
return new Array(segments).fill(null).map((_, key) => /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeSvg.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 = (0, _react.useMemo)(() => {
return progressAnimatedValues.map((animVal, key) => {
const target = segmentTargets[key] ?? 0;
const strokeDashoffset = computeStrokeDashoffset(animVal, target, circleCircumference, segments, segmentsGap);
return /*#__PURE__*/(0, _jsxRuntime.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]);
(0, _react.useImperativeHandle)(ref, () => ({
run
}), [run]);
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNativeSvg.default, {
viewBox: `0 0 ${halfCircle * 2} ${halfCircle * 2}`,
width: '100%',
fill: "none",
height: radius * 2,
children: [centerComponent && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.centerComponent,
children: centerComponent
}), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNativeSvg.G, {
children: [baseTrack, progressTrack, indicator?.show === true && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(IndicatorCircle, {
stroke: progressColor,
ref: indicatorCircleRef,
fill: "white"
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeSvg.TSpan, {
stroke: progressColor,
fill: progressColor,
ref: tSpanRef
})]
})]
})]
});
};
var _default = exports.default = /*#__PURE__*/(0, _react.memo)(/*#__PURE__*/(0, _react.forwardRef)(RNSegmentedProgressBar));
const styles = _reactNative.StyleSheet.create({
centerComponent: {
height: '100%',
justifyContent: 'center',
alignItems: 'center'
}
});
//# sourceMappingURL=index.js.map