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);