Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions src/SegmentedArc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down Expand Up @@ -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;
Expand Down
68 changes: 65 additions & 3 deletions src/__tests__/SegmentedArc.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,16 @@ describe('SegmentedArc', () => {
return render(<SegmentedArc {...properties} />);
};

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 };
});
Expand Down Expand Up @@ -305,15 +310,15 @@ 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(<SegmentedArc {...props} fillValue={25} />);
expect(Animated.timing).toHaveBeenCalledTimes(1);

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(<SegmentedArc {...props} fillValue={75} />);
expect(Animated.timing).toHaveBeenCalledTimes(1);
Expand All @@ -323,6 +328,63 @@ describe('SegmentedArc', () => {
expect(secondCallToValue).toBeGreaterThan(firstCallToValue);
});

it('re-runs animation when fillValue decreases dynamically', () => {
Animated.timing.mockReturnValue(createCompletedAnimationMock());

wrapper = render(<SegmentedArc {...props} fillValue={75} />);
expect(Animated.timing).toHaveBeenCalledTimes(1);

const firstCallToValue = Animated.timing.mock.calls[0][1].toValue;

Animated.timing.mockClear();
Animated.timing.mockReturnValue(createCompletedAnimationMock());

wrapper.rerender(<SegmentedArc {...props} fillValue={25} />);
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(<SegmentedArc {...props} fillValue={25} />);
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(<SegmentedArc {...props} fillValue={75} />);

// 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);
Expand Down