In Flutter, what does animating an Opacity widget by calling setState on every tick cost, compared with an animated fade widget?
answer
- build cost versus raster cost
- setState rebuilds the whole State subtree
- animation drives the render object
- mid-fade values still composite
- Image.opacity for a single image
basics
~20 ssetState per tick reruns build for that State's whole subtree every frame. FadeTransition or AnimatedOpacity update the render object's opacity with no rebuild. Both still pay the offscreen compositing cost while the value sits between 0 and 1.
solid answer
~40 sDriving `Opacity(opacity: _controller.value)` from a listener that calls `setState` marks the `State` dirty on every frame, so its `build` and every non-const descendant's `build` run 60 or 120 times a second on the UI thread. `FadeTransition` and `AnimatedOpacity` feed the animation straight to their render object, which only updates the composited `OpacityLayer`'s alpha; no widget rebuilds and the child is not repainted. What neither removes is the **raster** cost: while the value is strictly between 0 and 1 the child is usually composited through an offscreen buffer. So an animated fade fixes UI-thread jank, not raster-thread jank; for a single image, `Image`'s `opacity` parameter avoids the layer entirely.
code
dart · 33 linesimport 'package:flutter/material.dart';
class FadingPanel extends StatefulWidget {
const FadingPanel({super.key, required this.child});
final Widget child;
@override
State<FadingPanel> createState() => _FadingPanelState();
}
class _FadingPanelState extends State<FadingPanel>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
)..forward();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Costly alternative (do not do this):
// _controller.addListener(() => setState(() {}));
// return Opacity(opacity: _controller.value, child: widget.child);
//
// Here the animation drives the render object; build runs once.
return FadeTransition(opacity: _controller, child: widget.child);
}
}go deeper
Recall that calling setState every animation frame rebuilds your whole widget, and that FadeTransition or AnimatedOpacity exist to avoid that.
Separate build cost from raster cost and say which one each approach removes. Explain that the offscreen buffer remains for mid-fade values.
Diagnose which bar is over budget during the fade and pick the fix that matches: animated widget for UI jank, smaller area or draw-time alpha for raster jank.
Set team guidance on motion: which fades are allowed over large surfaces and on which device tiers, backed by profiled raster numbers.
## Two different costs hide in one fade Any fade in Flutter can cost time in two places, and they run on different threads: - **Build cost (UI thread).** Widgets are rebuilt when a `State` calls `setState`. Rebuilding means rerunning `build` methods, diffing the new widgets against the element tree and updating render objects. - **Raster cost (raster thread).** Once the layer tree is handed to the engine, a fractional opacity usually forces a **`saveLayer`**: the child is drawn into an offscreen buffer and blended back. The choice between `Opacity` + `setState` and an animated fade widget changes the **first** cost only. ## What setState on every tick does A common first attempt looks like this: an `AnimationController` with a listener that calls `setState(() {})`, and a `build` that returns `Opacity(opacity: _controller.value, child: ...)`. On every tick: 1. The `State` is marked dirty. 2. On the next frame its `build` runs again and returns a fresh widget tree. 3. Every descendant that is not a `const` instance is rebuilt as well, all the way down. 4. Only then does the new opacity value reach `RenderOpacity`. If the `State` owns a large screen, the whole screen rebuilds for a change that only needed one number. The `Opacity` API documentation names this directly: animating an `Opacity` widget directly causes the widget, and possibly its subtree, to rebuild each frame, and recommends `AnimatedOpacity` or `FadeTransition` instead. ## What an animated fade widget does instead `FadeTransition` takes an `Animation<double>` and `AnimatedOpacity` runs its own animation towards a target value. Both hand the changing value **directly to the render object**. When the value changes, the render object updates the alpha on its composited layer and asks for a new frame; it does not mark any widget dirty and does not repaint the child's drawing. | Approach | Rebuilds per frame | Child repainted per frame | Offscreen buffer mid-fade | |---|---|---|---| | `Opacity` + `setState` on each tick | the calling `State`'s subtree | no, unless rebuilt widgets change paint | usually yes | | `FadeTransition` / `AnimatedOpacity` | none | no | usually yes | | `Image` with `opacity: animation` | none | yes, the image redraws with the new alpha | no | ## The part an animated fade cannot fix Because both approaches end up with an opacity layer, every frame where the value is strictly between 0.0 and 1.0 still composites the child through an intermediate buffer. On a short fade over a small widget this is harmless. On a long fade over a full-screen subtree on a low-end GPU, the **raster** bar can still go over budget even though the UI bar is clean. At the end points the cost disappears: at 0.0 the child is not painted, and at 1.0 no blending is needed. ## Cheaper options for special cases - **One image:** `Image`'s `opacity` parameter multiplies the alpha while the image is drawn; its documentation says this is more efficient than `FadeTransition` because no composited layer is created. `FadeInImage` covers the placeholder-to-image case. - **One color:** animate the color's alpha (for example with `Color.lerp` or `withValues(alpha:)`) instead of wrapping it in a fade. - **Show or hide instantly:** no animation at all, just 0.0/1.0 or `Visibility`. ## How to tell which cost you have Profile in profile mode. If the UI bar is high while a fade runs and DevTools shows many widget builds, you have the `setState` pattern. If the raster bar is high and the UI bar is low, the fade widget is already in place and the remaining cost is the offscreen compositing, which only a smaller area, a shorter fade or a draw-time alpha can reduce.
- If FadeTransition already avoids rebuilds, why might a fade still drop frames on a cheap Android phone?Because mid-fade the child is still usually composited through an offscreen buffer on the raster thread. A large, complex child on a weak GPU can push raster time over budget. Shrink the faded area, shorten the fade, or move the alpha into a single image or color paint.
- In Flutter, does putting const on the Opacity's child help the setState-per-tick version?Partly. A `const` child is the same instance each rebuild, so Flutter skips rebuilding below it, but the `State`'s own `build` and any non-const siblings still run every frame. It trims the cost; switching to an animated fade widget removes it.
saying these in an interview costs you the question
- FadeTransition avoids the offscreen buffer, so fades cost nothing on the raster thread.
- Opacity driven by setState repaints the child's pixels every frame.
- AnimatedOpacity and FadeTransition rebuild their child on every tick.
- A janky fade is always a rebuild problem.
- Wrapping the fading child in RepaintBoundary removes the compositing cost.