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:
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 period
s 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)