diff --git a/src/SegmentedArc.js b/src/SegmentedArc.js
index a7e96f1..c9d310d 100644
--- a/src/SegmentedArc.js
+++ b/src/SegmentedArc.js
@@ -49,7 +49,7 @@ export const SegmentedArc = ({
}
const [arcAnimatedValue] = useState(new Animated.Value(0));
- const animationRunning = useRef(false);
+ const currentAnimation = useRef(null);
useShowSegmentedArcWarnings({ segments: segmentsProps });
const { dataErrors, segments, fillValue, filledArcWidth, emptyArcWidth, spaceBetweenSegments, arcDegree, radius } =
@@ -138,19 +138,38 @@ export const SegmentedArc = ({
useEffect(() => {
if (!lastFilledSegment) return;
- if (animationRunning.current) return;
if (!isAnimated) return;
- animationRunning.current = true;
- Animated.timing(arcAnimatedValue, {
+
+ // Cancel any in-flight animation to prevent dropped updates
+ if (currentAnimation.current) {
+ currentAnimation.current.stop();
+ }
+
+ const animation = Animated.timing(arcAnimatedValue, {
toValue: lastFilledSegment.filled,
duration: animationDuration,
delay: animationDelay,
useNativeDriver: false,
easing: Easing.out(Easing.ease)
- }).start(() => {
- animationRunning.current = false;
});
- }, [lastFilledSegment.filled]);
+
+ currentAnimation.current = animation;
+
+ animation.start(({ finished }) => {
+ // Only clear if this is still the current animation AND it finished successfully (not stopped)
+ if (currentAnimation.current === animation && finished) {
+ currentAnimation.current = null;
+ }
+ });
+
+ // Cleanup: stop this specific animation if it's still running
+ return () => {
+ if (currentAnimation.current === animation) {
+ animation.stop();
+ currentAnimation.current = null;
+ }
+ };
+ }, [lastFilledSegment.filled, animationDuration, animationDelay, isAnimated]);
if (arcs.length === 0) {
return null;
diff --git a/src/__tests__/SegmentedArc.spec.js b/src/__tests__/SegmentedArc.spec.js
index f5d517f..da0c47b 100644
--- a/src/__tests__/SegmentedArc.spec.js
+++ b/src/__tests__/SegmentedArc.spec.js
@@ -42,11 +42,16 @@ describe('SegmentedArc', () => {
return render();
};
+ const createCompletedAnimationMock = () => ({
+ start: jest.fn(cb => cb && cb({ finished: true })),
+ stop: jest.fn()
+ });
+
beforeEach(() => {
Animated.timing = jest.fn();
Easing.out = jest.fn();
Easing.ease = jest.fn();
- Animated.timing.mockReturnValue({ start: jest.fn() });
+ Animated.timing.mockReturnValue({ start: jest.fn(), stop: jest.fn() });
jest.spyOn(console, 'warn').mockImplementation();
props = { segments, fillValue: 50 };
});
@@ -305,7 +310,7 @@ describe('SegmentedArc', () => {
});
it('re-runs animation when fillValue changes dynamically', () => {
- Animated.timing.mockReturnValue({ start: jest.fn(cb => cb && cb()) });
+ Animated.timing.mockReturnValue(createCompletedAnimationMock());
wrapper = render();
expect(Animated.timing).toHaveBeenCalledTimes(1);
@@ -313,7 +318,7 @@ describe('SegmentedArc', () => {
const firstCallToValue = Animated.timing.mock.calls[0][1].toValue;
Animated.timing.mockClear();
- Animated.timing.mockReturnValue({ start: jest.fn(cb => cb && cb()) });
+ Animated.timing.mockReturnValue(createCompletedAnimationMock());
wrapper.rerender();
expect(Animated.timing).toHaveBeenCalledTimes(1);
@@ -323,6 +328,63 @@ describe('SegmentedArc', () => {
expect(secondCallToValue).toBeGreaterThan(firstCallToValue);
});
+ it('re-runs animation when fillValue decreases dynamically', () => {
+ Animated.timing.mockReturnValue(createCompletedAnimationMock());
+
+ wrapper = render();
+ expect(Animated.timing).toHaveBeenCalledTimes(1);
+
+ const firstCallToValue = Animated.timing.mock.calls[0][1].toValue;
+
+ Animated.timing.mockClear();
+ Animated.timing.mockReturnValue(createCompletedAnimationMock());
+
+ wrapper.rerender();
+ expect(Animated.timing).toHaveBeenCalledTimes(1);
+
+ const secondCallToValue = Animated.timing.mock.calls[0][1].toValue;
+ expect(secondCallToValue).not.toBe(firstCallToValue);
+ expect(secondCallToValue).toBeLessThan(firstCallToValue);
+ });
+
+ it('cancels in-flight animation when fillValue changes before animation completes', () => {
+ let firstAnimationCallback;
+ const mockStop = jest.fn();
+ const mockStart = jest.fn(cb => {
+ firstAnimationCallback = cb;
+ });
+ Animated.timing.mockReturnValue({ start: mockStart, stop: mockStop });
+
+ wrapper = render();
+ expect(Animated.timing).toHaveBeenCalledTimes(1);
+ expect(mockStart).toHaveBeenCalledTimes(1);
+
+ // Simulate fillValue changing before animation completes
+ Animated.timing.mockClear();
+ const newMockStop = jest.fn();
+ const newMockStart = jest.fn();
+ Animated.timing.mockReturnValue({ start: newMockStart, stop: newMockStop });
+
+ wrapper.rerender();
+
+ // Verify that the previous animation was stopped
+ expect(mockStop).toHaveBeenCalled();
+ // Verify that a new animation was started
+ expect(Animated.timing).toHaveBeenCalledTimes(1);
+ expect(newMockStart).toHaveBeenCalledTimes(1);
+
+ // Simulate the stopped animation's callback executing with finished: false
+ // This should NOT clear currentAnimation (because finished is false)
+ if (firstAnimationCallback) {
+ firstAnimationCallback({ finished: false });
+ }
+
+ // Verify that the new animation is still active by unmounting and checking
+ // that its stop method is called
+ wrapper.unmount();
+ expect(newMockStop).toHaveBeenCalled();
+ });
+
it('sets the last segment for lastFilledSegment prop when fillValue is equal or greater than 100%', () => {
props.fillValue = 100;
wrapper = getWrapper(props);