DEV Community

Cover image for A finite loop in skeleton_shimmer that stopped resuming when the loop count was raised
Yusuf İhsan Görgel
Yusuf İhsan Görgel

Posted on

A finite loop in skeleton_shimmer that stopped resuming when the loop count was raised

An animated illustration of two skeleton cards, the left frozen after a finite loop finishes and the right still sweeping

The clip above is a conceptual illustration of the behavior, with a card frozen after a finite loop on the left and a card still sweeping on the right. It is not a screen recording of the running widget.

skeleton_shimmer draws a shimmer loading effect, a light gradient that sweeps across gray skeleton placeholders while data loads. It is API-compatible with the shimmer package. Its Shimmer widget takes a loop parameter: loop: 0 sweeps forever, and loop: N stops after N sweeps.

Version 0.2.1 had a bug in the loop: N path. Once a finite loop finished, raising loop at runtime did not start the sweep again. The widget stayed frozen on a static gray field. Version 0.2.2 fixes it with a one-line change, and the reason that line works comes down to how AnimationController behaves when it is parked at one of its bounds.

The symptom

Give the widget loop: 2 and let both sweeps play. The shimmer settles on gray, which is what loop: 2 asks for. Now raise the count to loop: 4 while the widget is on screen. Before 0.2.2, nothing happened. The placeholder stayed gray and static, and the two extra sweeps never ran.

Root cause

The sweep is driven by an AnimationController that animates from its lower bound to its upper bound, 0.0 to 1.0. One pass from 0.0 to 1.0 is one sweep.

A status listener, _onStatus, counts completed passes. Each time the controller reaches the end it increments a completed-loops counter, _completedLoops. While that counter is below the target it calls forward(from: 0) to run the next sweep. Once the counter reaches the target it stops calling forward(from: 0), and the controller is left parked at the upper bound: value is 1.0, its status is completed, and it is not animating. For loop: N that is correct, since the animation is meant to stop after N sweeps.

Raising loop at runtime goes through didUpdateWidget. That resets the completed-loops counter back to 0 and calls a resync method, _syncAnimation, to line the animation up with the new configuration.

_syncAnimation saw that the controller was not animating and tried to resume it with a bare forward(). That is where it went wrong. forward() animates toward the upper bound, and the controller was already sitting at the upper bound with value at 1.0. The remaining distance was zero, so the call was a zero-duration no-op: it scheduled no ticker and did not fire the status listener again. The sweep never restarted, and the widget stayed frozen.

The framework behavior underneath it

This is deterministic, and you can confirm it away from the package with a plain AnimationController. Calling forward() when value is already 1.0 does nothing: the status stays completed and isAnimating stays false. It is a genuine no-op. Calling forward(from: 0) instead sets value to 0.0 and isAnimating to true, and the controller runs again.

The fix

The change is one line in _syncAnimation. The branch before 0.2.2:

  } else if (!_controller.isAnimating) {
    _controller.forward();
  }
Enter fullscreen mode Exit fullscreen mode

And after:

  } else if (!_controller.isAnimating) {
    // A finished finite loop parks the controller at the upper bound, where
    // forward() would be a no-op; restart from 0 so a raised loop count
    // sweeps again. Otherwise (e.g. re-enabled mid-sweep) resume in place.
    _controller.forward(from: _controller.isCompleted ? 0 : _controller.value);
  }
Enter fullscreen mode Exit fullscreen mode

The ternary picks a starting point based on where the controller is parked.

When the controller is completed, meaning it is parked at the upper bound after a finished finite loop, forward(from: 0) restarts it from the beginning. That is the case the old code got wrong, and it matches what the status listener already does between loops.

When the controller is not completed, for example it was stopped mid-sweep because the widget was disabled and is now being re-enabled, forward(from: _controller.value) starts from the value the controller already holds. Setting the start to the current value is a no-op assignment, and the controller forwards from there. For that path the behavior is identical to the old bare forward(). Only the completed case changed.

Two behaviors that had to keep working

First, toggling the widget's enabled flag off and back on in the middle of a sweep resumes in place rather than jumping to the start. A controller stopped mid-sweep is not completed, so the fix sends it down the resume-in-place branch, forward(from: _controller.value).

Second, reduced motion still holds. When the platform's disableAnimations accessibility setting is on, the shimmer freezes on its base color. That path calls _controller.stop(), and the fix does not touch it.

The regression test

A regression test shipped with the fix for exactly this scenario. It mounts the widget with loop: 2 and pumps until the animation settles. Once the finite loop is done the controller stops, so the transient callback count, the number of one-shot callbacks registered for the next frame, falls back to 0. The test then rebuilds with loop: 4 and checks that the sweep resumes, which shows up as the transient callback count going above 0 again.

On the pre-fix code the count stays at 0, because the frozen controller schedules no ticker. After the fix it goes back above 0. I confirmed this by reverting only the fix and rerunning: the test fails before and passes after.

An AnimationController parked at a bound is a quiet trap

Calling forward() toward the bound you are already at is a no-op, and so is reverse() toward the bound you are already at, because the remaining distance is zero. When you want the controller to run again from that bound, you have to say so: forward(from: 0) from the upper bound, or reverse(from: 1) from the lower bound.

The other half of this bug sits in didUpdateWidget. When a runtime parameter change resets internal state, here the completed-loops counter, it has to re-establish the animation to match the new state. Resuming a controller and restarting it are different operations, and the code has to choose the right one for where the controller is parked at that moment.

Top comments (0)