Have you ever wondered what actually happens inside Flutter when you press a button and watch a smooth animation? Thanks to a simple API, in many business cases everything works great out of the box, and there is no need to dig deep into how things work under the hood. But this is not always the case, and sooner or later every engineer will have to figure it out: why is my animation not smooth, and how does this whole path from “widget to pixels on the screen” actually work?
In this series of articles, we will dive into Flutter’s source code to trace the path of an animation — from the moment a developer writes a widget in code to the moment physical pixels change on the screen.
In the first article (this one), we will cover the fundamentals: how Flutter synchronizes with the display refresh rate and where the path of your animation begins.
In the second article (I'm gonna post it tomorrow), we will explore rendering mechanics and how everything turns into real pixels on the screen.
How Flutter Synchronizes with the Screen
Any movement on the screen starts with a hardware signal. In modern displays, this is the Vsync signal (vertical synchronization), which tells the system that the screen is ready to display a new frame. In Flutter, this process is managed by SchedulerBinding.
When we say that at 120 Hz a Ticker is called 120 times per second, it means that the budget for each frame is only 8.3 ms. Within these 8.3 ms, the system must process input, animations, tree rebuilding, layout, and painting. Ticker is the mechanism that “subscribes” to this pulse.
Frame scheduling mechanism: who calls the shots?
To understand how a frame is scheduled, we need to examine the chain of calls from Dart to the Engine and back.
When you call
AnimationController.forward(), the controller asks itsTickerto start working. TheTickercallsSchedulerBinding.instance.scheduleFrame().Inside
SchedulerBinding.scheduleFrame(), flags for repeated calls are checked, and then the call is forwarded toPlatformDispatcher.instance.scheduleFrame(). The Binding layer passes the request intodart:ui, which is the bridge to the native Engine.The Engine receives the request but does not start rendering immediately. It waits for the next Vsync signal from the operating system (Android/iOS).
As soon as the OS sends Vsync, the Engine “wakes up” and calls the
onBeginFramecallback registered inPlatformDispatcher. This returns control back to the Flutter Framework, triggering thehandleBeginFramemethod.
void scheduleFrame() { if (_hasScheduledFrame ||!framesEnabled) { return; } ensureFrameCallbacksRegistered(); platformDispatcher.scheduleFrame(); // Going to the native Engine via the bridge _hasScheduledFrame = true; }
Source: flutter/lib/src/scheduler/binding.dart
What happens when calling .forward() on AnimationController?
When you call forward(), you initialize a physical simulation (Simulation) and switch the Ticker into an active state.
What is the difference between forward() and addListener()?
forward()— this is the “start command.” It makes the controller begin scheduling frames viaTicker.addListener()— this is a “subscription to the result.” You simply register a function in the list of listeners (AnimationLocalListenersMixin). This function will be called inside the controller’s_tickmethod when a new value has already been computed.
Analysis of AnimationController._tick source code
Let’s take a look at how AnimationController processes each “tick” of time:
void _tick(Duration elapsed) { _lastElapsedDuration = elapsed; final double elapsedInSeconds = elapsed.inMicroseconds.toDouble() / Duration.microsecondsPerSecond; assert(elapsedInSeconds >= 0.0); // 1. Math: calculating the value via simulation _value = clampDouble(_simulation!.x(elapsedInSeconds), lowerBound, upperBound); // 2. Completion logic if (_simulation!.isDone(elapsedInSeconds)) { _status = (_direction == _AnimationDirection.forward)? AnimationStatus.completed : AnimationStatus.dismissed; stop(canceled: false); } // 3. Notification: this is where your addListener callbacks are called! notifyListeners(); _checkStatusChanged(); }
Source: flutter/lib/src/animation/animation_controller.dart
When notifyListeners() is called, the framework iterates over the list of listeners. If you added ..addListener(() => setState(() {})), this is exactly the moment when setState is called, marking your element as dirty.
Frame Phases
When the Engine calls handleBeginFrame, the “animation start phase” begins. Immediately after that, SchedulerBinding starts invoking transientCallbacks. At this moment, all functions registered via scheduleFrameCallback are executed (including all active Tickers).
Let’s break this process down step by step:
Animation start phase: The Engine calls
handleBeginFrame. At this moment, all active animations “tick.” This means that for each controller, the_tickmethod is executed: a new value is calculated (for example, opacity becomes 0.5 instead of 0.4), and listeners are called.Between phases: When the last ticker has finished, the
transientCallbacksphase ends. At this point, all variable values in memory have already been updated. However, the user still sees old pixels on the screen.Microtasks (
midFrameMicrotasks): After animations, Flutter gives a chance for microtasks to run (for example, completion ofFutures from tickers).Rendering phase (
persistentCallbacks): Only now does the Engine callhandleDrawFrame, which starts the main build method —WidgetsBinding.drawFrame.
@override void drawFrame() { try { if (rootElement!= null) buildOwner!.buildScope(rootElement!); // BUILD block super.drawFrame(); // Passing execution to RendererBinding for LAYOUT and PAINT buildOwner!.finalizeTree(); } finally { //... } }
Source: flutter/lib/src/widgets/binding.dart
What happens next inside?
Build Phase:
buildOwner!.buildScope()takes all elements marked asdirtyduring the animation phase (remembersetStateinsideaddListener?) and rebuilds them. This creates new element configurations.Layout Phase:
super.drawFrame()is called, which passes execution intoRendererBinding.drawFrame. Here,PipelineOwnercalculates sizes and positions for allRenderObjects.Paint Phase: The same
PipelineOwnerrecords drawing commands into theLayertree.Composite Phase: The completed layer tree is packaged into a
Sceneand sent back to the Engine viarenderView.compositeFrame().
One of the performance issues
Before wrapping up this article, it’s worth highlighting one performance issue — namely, how you update animations.
The difference between using setState in a controller listener and using AnimatedBuilder is a question of how much extra work Flutter performs 120 times per second.
Problem with setState in complex widgets
Imagine you have a complex screen with charts, lists, and text, and you want to animate only a small icon. If you write controller.addListener(() => setState(() {})) in the root widget of this screen, then on every tick (every 8.3 ms), Flutter will call the build() method again for the entire screen.
How AnimatedBuilder solves this
AnimatedBuilder works differently. It subscribes to the controller itself. When the value changes, AnimatedBuilder marks only itself as dirty, not its parent. As a result, only the necessary part of the tree is rebuilt during the Build phase.
Optimization via pre-built subtree (child parameter)
AnimatedBuilder( animation: controller, child: const HeavyWidget(), // Created ONCE builder: (context, child) { // child here is that same HeavyWidget return Transform.rotate( angle: controller.value * 2 * math.pi, child: child, // Flutter simply reuses the ready object ); }, )
In this case, Flutter creates HeavyWidget once during initialization. On each animation tick, it simply “wraps” the already built object in a Transform. This saves the system from recalculating this widget’s parameters, which is critical for maintaining stable 60/120 FPS.
Summary
In the first article, we explored how Vsync “wakes up” the framework and where the animation path begins. We reached the point where rendering instructions are prepared but not yet drawn.
But how do these instructions turn into real pixels?
In the second article, we will explore rendering mechanics and how everything becomes actual pixels on the screen.
