How to properly use curve's value to animate a widget?

Issue

Minimal reproducible code:

class _MyPageState extends State<MyPage> {
  double _dx1 = 0;
  double _dx2 = 0;
  final Duration _duration = Duration(seconds: 1);
  final Curve _curve = Curves.linear;

  void _play() {
    final width = MediaQuery.of(context).size.width;
    _dx1 = width;
    var i = 0;
    Timer.periodic(Duration(milliseconds: 1), (timer) {
      if (i > 1000) {
        timer.cancel();
      } else {
        setState(() {
          _dx2 = _curve.transform(i / 1000) * width;
          i++;
        });
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _play,
        child: Icon(Icons.play_arrow),
      ),
      body: SizedBox.expand(
        child: Stack(
          children: [
            AnimatedPositioned(
              left: _dx1,
              duration: _duration,
              curve: _curve,
              child: _box,
            ),
            Positioned(
              left: _dx2,
              top: 60,
              child: _box,
            ),
          ],
        ),
      ),
    );
  }

  Container get _box => Container(width: 50, height: 50, color: Colors.red);
}

Output:

enter image description here

As you can see my second custom animated box doesn’t catch up with the first default AnimatedPositioned widget.

Note: This can easily be done using Tween and AnimationController but I just want to know why I’m unable to correctly use Curve‘s value to match the default behavior.

Solution

Assumption: The callback is periodically called for every millisecond.
Expected Result: After 1 second, i = 1000;

The assumption is wrong. Add the below code to verify:

void _play() {
  ...
  var i = 0;
  final start = DateTime.now().millisecondsSinceEpoch;
  Timer.periodic(Duration(milliseconds: 1), (timer) {
    final elapse = DateTime.now().millisecondsSinceEpoch - start;
    print('elapse = $elapse');
    print('i = $i');
    print('ticks = ${timer.tick}');
    print('######################');
    ...
  });
}

On my pc the last value is:

elapse = 14670
i = 1001
ticks = 14670
######################

So this implies that it took 14 seconds on my PC for 1001 callbacks. That’s not what we were expecting. What we can infer from this is that some callbacks are missed and i does not reflect the time elapsed.

However, the value we need is timer.tick. Quoting the docs

If a periodic timer with a non-zero duration is delayed too much, so more than one tick should have happened, all but the last tick in the past are considered "missed", and no callback is invoked for them. The tick count reflects the number of durations that have passed and not the number of callback invocations that have happened.

So, tick tells us the number of periods that have passed. The below code will catch up with the AnimatedPositioned

void _play() {
  final width = MediaQuery.of(context).size.width;
  _dx1 = width;

  final period = Duration(milliseconds: 1);
  Timer.periodic(period, (timer) {
    final elapsed = timer.tick * period.inMilliseconds;
    if (elapsed > _duration.inMilliseconds) {
      timer.cancel();
    } else {
      setState(() {
        _dx2 = _curve.transform(elapsed / _duration.inMilliseconds) * width;
      });
    }
  });
}

Now, you might see some stutter where our box might be a bit ahead or behind the AnimatedPositioned, this is because we use Timer but the AnimatedPositioned uses Ticker. The difference is Timer.periodic is driven by the Duration we passed as period, but Ticker is driven by SchedulerBinding.scheduleFrameCallback. So, the instant the value _dx2 is updated and the instant the frame is rendered on the screen might not be the same. Add the fact that some callbacks are missed!

Answered By – Navaneeth P

Answer Checked By – Clifford M. (FlutterFixes Volunteer)

Leave a Reply

Your email address will not be published.