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.

  1. When you call AnimationController.forward(), the controller asks its Ticker to start working. The Ticker calls SchedulerBinding.instance.scheduleFrame().

  2. Inside SchedulerBinding.scheduleFrame(), flags for repeated calls are checked, and then the call is forwarded to PlatformDispatcher.instance.scheduleFrame(). The Binding layer passes the request into dart:ui, which is the bridge to the native Engine.

  3. The Engine receives the request but does not start rendering immediately. It waits for the next Vsync signal from the operating system (Android/iOS).

  4. As soon as the OS sends Vsync, the Engine “wakes up” and calls the onBeginFrame callback registered in PlatformDispatcher. This returns control back to the Flutter Framework, triggering the handleBeginFrame method.

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 via Ticker.

  • 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 _tick method 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:

  1. Animation start phase: The Engine calls handleBeginFrame. At this moment, all active animations “tick.” This means that for each controller, the _tick method is executed: a new value is calculated (for example, opacity becomes 0.5 instead of 0.4), and listeners are called.

  2. Between phases: When the last ticker has finished, the transientCallbacks phase ends. At this point, all variable values in memory have already been updated. However, the user still sees old pixels on the screen.

  3. Microtasks (midFrameMicrotasks): After animations, Flutter gives a chance for microtasks to run (for example, completion of Futures from tickers).

  4. Rendering phase (persistentCallbacks): Only now does the Engine call handleDrawFrame, 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?

  1. Build Phase: buildOwner!.buildScope() takes all elements marked as dirty during the animation phase (remember setState inside addListener?) and rebuilds them. This creates new element configurations.

  2. Layout Phase: super.drawFrame() is called, which passes execution into RendererBinding.drawFrame. Here, PipelineOwner calculates sizes and positions for all RenderObjects.

  3. Paint Phase: The same PipelineOwner records drawing commands into the Layer tree.

  4. Composite Phase: The completed layer tree is packaged into a Scene and sent back to the Engine via renderView.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.