Skip to content

Commit 2758b23

Browse files
jkhusanovCopilot
andauthored
Animation fix - support dynamic fillValue (#141)
* Support dynamic fill value * Fix segments animation when going back * Fix animation race condition for dynamic fillValue updates (#144) * Initial plan * Fix animation race condition by cancelling in-flight animations Co-authored-by: jkhusanov <25942541+jkhusanov@users.noreply.github.com> * Add test for animation cancellation during updates Co-authored-by: jkhusanov <25942541+jkhusanov@users.noreply.github.com> * Properly handle animation reference cleanup after cancellation Co-authored-by: jkhusanov <25942541+jkhusanov@users.noreply.github.com> * Improve animation cleanup logic to prevent race conditions Co-authored-by: jkhusanov <25942541+jkhusanov@users.noreply.github.com> * Refine animation cleanup to prevent race conditions Co-authored-by: jkhusanov <25942541+jkhusanov@users.noreply.github.com> * Address final code review feedback Co-authored-by: jkhusanov <25942541+jkhusanov@users.noreply.github.com> * Clear animation reference in cleanup and improve test helper reusability Co-authored-by: jkhusanov <25942541+jkhusanov@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jkhusanov <25942541+jkhusanov@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent a178f2c commit 2758b23

3 files changed

Lines changed: 97 additions & 18 deletions

File tree

src/SegmentedArc.js

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const SegmentedArc = ({
4949
}
5050

5151
const [arcAnimatedValue] = useState(new Animated.Value(0));
52-
const animationRunning = useRef(false);
52+
const currentAnimation = useRef(null);
5353
useShowSegmentedArcWarnings({ segments: segmentsProps });
5454

5555
const { dataErrors, segments, fillValue, filledArcWidth, emptyArcWidth, spaceBetweenSegments, arcDegree, radius } =
@@ -138,22 +138,38 @@ export const SegmentedArc = ({
138138

139139
useEffect(() => {
140140
if (!lastFilledSegment) return;
141-
if (animationRunning.current) return;
142141
if (!isAnimated) return;
143-
animationRunning.current = true;
144-
Animated.timing(arcAnimatedValue, {
142+
143+
// Cancel any in-flight animation to prevent dropped updates
144+
if (currentAnimation.current) {
145+
currentAnimation.current.stop();
146+
}
147+
148+
const animation = Animated.timing(arcAnimatedValue, {
145149
toValue: lastFilledSegment.filled,
146150
duration: animationDuration,
147151
delay: animationDelay,
148152
useNativeDriver: false,
149153
easing: Easing.out(Easing.ease)
150-
}).start();
154+
});
151155

152-
const listenerId = arcAnimatedValue.addListener(e => {
153-
if (e.value === lastFilledSegment.filled) animationRunning.current = false;
156+
currentAnimation.current = animation;
157+
158+
animation.start(({ finished }) => {
159+
// Only clear if this is still the current animation AND it finished successfully (not stopped)
160+
if (currentAnimation.current === animation && finished) {
161+
currentAnimation.current = null;
162+
}
154163
});
155-
return () => arcAnimatedValue.removeListener(listenerId);
156-
}, []);
164+
165+
// Cleanup: stop this specific animation if it's still running
166+
return () => {
167+
if (currentAnimation.current === animation) {
168+
animation.stop();
169+
currentAnimation.current = null;
170+
}
171+
};
172+
}, [lastFilledSegment.filled, animationDuration, animationDelay, isAnimated]);
157173

158174
if (arcs.length === 0) {
159175
return null;

src/__tests__/SegmentedArc.spec.js

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,16 @@ describe('SegmentedArc', () => {
4242
return render(<SegmentedArc {...properties} />);
4343
};
4444

45+
const createCompletedAnimationMock = () => ({
46+
start: jest.fn(cb => cb && cb({ finished: true })),
47+
stop: jest.fn()
48+
});
49+
4550
beforeEach(() => {
4651
Animated.timing = jest.fn();
4752
Easing.out = jest.fn();
4853
Easing.ease = jest.fn();
49-
Animated.timing.mockReturnValue({ start: jest.fn() });
54+
Animated.timing.mockReturnValue({ start: jest.fn(), stop: jest.fn() });
5055
jest.spyOn(console, 'warn').mockImplementation();
5156
props = { segments, fillValue: 50 };
5257
});
@@ -304,6 +309,63 @@ describe('SegmentedArc', () => {
304309
expect(Easing.out).not.toHaveBeenCalledWith(Easing.ease);
305310
});
306311

312+
it('re-runs animation when fillValue changes dynamically', () => {
313+
Animated.timing.mockReturnValue(createCompletedAnimationMock());
314+
315+
wrapper = render(<SegmentedArc {...props} fillValue={25} />);
316+
expect(Animated.timing).toHaveBeenCalledTimes(1);
317+
318+
const firstCallToValue = Animated.timing.mock.calls[0][1].toValue;
319+
320+
Animated.timing.mockClear();
321+
Animated.timing.mockReturnValue(createCompletedAnimationMock());
322+
323+
wrapper.rerender(<SegmentedArc {...props} fillValue={75} />);
324+
expect(Animated.timing).toHaveBeenCalledTimes(1);
325+
326+
const secondCallToValue = Animated.timing.mock.calls[0][1].toValue;
327+
expect(secondCallToValue).not.toBe(firstCallToValue);
328+
expect(secondCallToValue).toBeGreaterThan(firstCallToValue);
329+
});
330+
331+
it('cancels in-flight animation when fillValue changes before animation completes', () => {
332+
let firstAnimationCallback;
333+
const mockStop = jest.fn();
334+
const mockStart = jest.fn(cb => {
335+
firstAnimationCallback = cb;
336+
});
337+
Animated.timing.mockReturnValue({ start: mockStart, stop: mockStop });
338+
339+
wrapper = render(<SegmentedArc {...props} fillValue={25} />);
340+
expect(Animated.timing).toHaveBeenCalledTimes(1);
341+
expect(mockStart).toHaveBeenCalledTimes(1);
342+
343+
// Simulate fillValue changing before animation completes
344+
Animated.timing.mockClear();
345+
const newMockStop = jest.fn();
346+
const newMockStart = jest.fn();
347+
Animated.timing.mockReturnValue({ start: newMockStart, stop: newMockStop });
348+
349+
wrapper.rerender(<SegmentedArc {...props} fillValue={75} />);
350+
351+
// Verify that the previous animation was stopped
352+
expect(mockStop).toHaveBeenCalled();
353+
// Verify that a new animation was started
354+
expect(Animated.timing).toHaveBeenCalledTimes(1);
355+
expect(newMockStart).toHaveBeenCalledTimes(1);
356+
357+
// Simulate the stopped animation's callback executing with finished: false
358+
// This should NOT clear currentAnimation (because finished is false)
359+
if (firstAnimationCallback) {
360+
firstAnimationCallback({ finished: false });
361+
}
362+
363+
// Verify that the new animation is still active by unmounting and checking
364+
// that its stop method is called
365+
wrapper.unmount();
366+
expect(newMockStop).toHaveBeenCalled();
367+
});
368+
307369
it('sets the last segment for lastFilledSegment prop when fillValue is equal or greater than 100%', () => {
308370
props.fillValue = 100;
309371
wrapper = getWrapper(props);

src/components/Segment.js

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,23 @@ export const Segment = ({ arc }) => {
1212
const { filledArcWidth, radius, isAnimated, emptyArcWidth, arcAnimatedValue } = segmentedArcContext;
1313

1414
const arcRef = useRef();
15-
const animationComplete = useRef(false);
1615

1716
useEffect(() => {
1817
if (!isAnimated) return;
1918
const listener = arcAnimatedValue.addListener(v => {
2019
if (!arcRef.current) return;
21-
if (animationComplete.current) return;
22-
if (v.value <= arc.start) return;
20+
21+
if (v.value <= arc.start) {
22+
arcRef.current.setNativeProps({
23+
d: drawArc(arc.centerX, arc.centerY, radius, arc.start, arc.start)
24+
});
25+
return;
26+
}
2327

2428
if (v.value >= arc.end) {
2529
arcRef.current.setNativeProps({
2630
d: drawArc(arc.centerX, arc.centerY, radius, arc.start, arc.end)
2731
});
28-
animationComplete.current = true;
2932
} else {
3033
arcRef.current.setNativeProps({
3134
d: drawArc(arc.centerX, arc.centerY, radius, arc.start, v.value)
@@ -34,7 +37,7 @@ export const Segment = ({ arc }) => {
3437
});
3538

3639
return () => arcAnimatedValue.removeListener(listener);
37-
}, []);
40+
}, [arc.start, arc.end, arc.centerX, arc.centerY, isAnimated, arcAnimatedValue, radius]);
3841

3942
return (
4043
<G>
@@ -45,9 +48,7 @@ export const Segment = ({ arc }) => {
4548
d={drawArc(arc.centerX, arc.centerY, radius, arc.start, arc.end)}
4649
/>
4750

48-
{isAnimated && arc.filled > arc.start && (
49-
<AnimatedPath ref={arcRef} fill="none" stroke={arc.filledColor} strokeWidth={filledArcWidth} />
50-
)}
51+
{isAnimated && <AnimatedPath ref={arcRef} fill="none" stroke={arc.filledColor} strokeWidth={filledArcWidth} />}
5152

5253
{!isAnimated && arc.filled > arc.start && (
5354
<Path

0 commit comments

Comments
 (0)