Impeller was designed to solve the shader compilation jank that plagued Skia. However, migrating to the Impeller runtime often introduces a different class of rendering issues: flickering artifacts, aggressive battery drain, and frame drops during complex animations.
These issues are most prevalent when using BackdropFilter (for glassmorphism), Opacity layers, or complex clipping within scrollable views. While shader compilation is gone, you are now hitting limits in GPU bandwidth and Render Pass management.
The Root Cause: Render Pass Explosion
To understand why your UI flickers, you must understand how Impeller renders a frame compared to Skia.
Impeller functions using an Entity Component System (ECS) approach to rendering. It builds a tree of "Entities" which are then flattened into "Entity Passes."
When you use BackdropFilter, Impeller cannot simply draw a sprite. It must:
- Flush the current render pass (write everything drawn so far to a texture).
- Read that texture back into memory.
- Apply the Gaussian blur shader to that texture.
- Draw the result into a new render pass.
This is a Render Target switch.
If you place a BackdropFilter inside a ListView, and you have 10 items visible, you are forcing the GPU to switch render targets, resolve textures, and blur them 10 to 20 times per frame.
The "flickering" or black artifacts occur when the Command Buffer gets saturated. The GPU pipeline fails to synchronize the read-back of the texture before the next draw call is issued, resulting in uninitialized memory (black squares) or stale buffer data (visual glitches) being rendered.
The Solution: Adaptive Isolate Rendering
We cannot change how the GPU works, but we can control when these expensive render passes occur.
The fix involves two distinct strategies applied simultaneously:
- Layer Isolation: wrapping expensive painting operations in
RepaintBoundaryto prevent parent/child dirty chain propagation. - Dynamic Complexity Scaling: downgrading the visual fidelity (disabling the expensive blur) specifically during scroll events when the user's focus is on motion, not texture details.
Implementation
Below is a production-ready component, HighPerformanceGlass, which replaces standard BackdropFilter widgets. It handles layer promotion and automatically degrades to a static opacity layer when the application determines the frame budget is tight (e.g., during scrolling).
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
/// A controller to manage global scroll state or performance mode
class PerformanceController extends ChangeNotifier {
bool _isScrolling = false;
bool get isScrolling => _isScrolling;
void setScrolling(bool value) {
if (_isScrolling != value) {
_isScrolling = value;
notifyListeners();
}
}
}
/// A highly optimized Glassmorphism container that prevents
/// Impeller render pass thrashing during scroll.
class HighPerformanceGlass extends StatefulWidget {
final Widget child;
final double blurSigma;
final Color tintColor;
final BorderRadius borderRadius;
final PerformanceController performanceController;
const HighPerformanceGlass({
super.key,
required this.child,
required this.performanceController,
this.blurSigma = 10.0,
this.tintColor = const Color(0x1AFFFFFF),
this.borderRadius = BorderRadius.zero,
});
@override
State<HighPerformanceGlass> createState() => _HighPerformanceGlassState();
}
class _HighPerformanceGlassState extends State<HighPerformanceGlass> {
@override
void initState() {
super.initState();
// Listen to performance/scroll state changes
widget.performanceController.addListener(_onPerformanceChange);
}
@override
void dispose() {
widget.performanceController.removeListener(_onPerformanceChange);
super.dispose();
}
void _onPerformanceChange() {
// Trigger a rebuild when scroll state changes
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
// 1. Optimization: Clip early to reduce fragment shader bounds
return ClipRRect(
borderRadius: widget.borderRadius,
child: _buildOptimizedLayer(),
);
}
Widget _buildOptimizedLayer() {
// 2. Optimization: If scrolling, disable the offscreen pass (BackdropFilter)
// completely. Replace with a cheap alpha blend.
if (widget.performanceController.isScrolling) {
return Container(
color: widget.tintColor.withOpacity(0.85), // Fallback opacity
child: widget.child,
);
}
// 3. Optimization: Isolate the expensive paint operation.
// This creates a separate display list for the blur, preventing
// surrounding widget changes from triggering a repaint of the filter.
return RepaintBoundary(
child: Stack(
children: [
// The expensive offscreen pass
BackdropFilter(
filter: ImageFilter.blur(
sigmaX: widget.blurSigma,
sigmaY: widget.blurSigma,
// Explicit TileMode is safer for Impeller edge-cases
tileMode: TileMode.clamp,
),
child: Container(
color: widget.tintColor,
),
),
widget.child,
],
),
);
}
}
Usage in a ListView
To make this work, you must hook into the scroll notifications of your list. This prevents the "Scroll Jank" by temporarily removing the heavy shader logic while the list is moving.
class OptimizedScrollList extends StatefulWidget {
const OptimizedScrollList({super.key});
@override
State<OptimizedScrollList> createState() => _OptimizedScrollListState();
}
class _OptimizedScrollListState extends State<OptimizedScrollList> {
final PerformanceController _perfController = PerformanceController();
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollStartNotification) {
_perfController.setScrolling(true);
} else if (notification is ScrollEndNotification) {
_perfController.setScrolling(false);
}
return false;
},
child: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
height: 80,
child: HighPerformanceGlass(
performanceController: _perfController,
borderRadius: BorderRadius.circular(12),
child: Center(
child: Text(
'Glass Item $index',
style: const TextStyle(color: Colors.white),
),
),
),
),
);
},
),
);
}
}
Why This Fix Works
1. Eliminating Render Target Switches
By swapping BackdropFilter for a simple opaque Container during scrolls, we reduce the complexity of the Impeller Entity Pass from $O(N)$ (where N is visible items) to $O(1)$. The GPU no longer needs to interrupt the main render pass to resolve a texture for reading. This frees up bandwidth for handling the geometry updates required for smooth scrolling.
2. RepaintBoundary Isolation
When the list is stationary, we re-enable the blur. However, we wrap it in RepaintBoundary. In Impeller, RepaintBoundary creates a Subpass. If the parent (the ListView) scrolls by a sub-pixel amount, or if a sibling widget updates, the expensive blur texture cached in the boundary does not necessarily need to be regenerated if the content behind it hasn't conceptually changed relative to the frame of reference.
Note: With BackdropFilter, strict caching is difficult because the background changes as you scroll. However, the RepaintBoundary ensures that unrelated state updates within the child widget (like a text counter or loading spinner inside the glass card) do not trigger a re-computation of the blur shader.
3. Explicit TileMode
In the code above, we added tileMode: TileMode.clamp.
filter: ImageFilter.blur(..., tileMode: TileMode.clamp)
Default tile modes in older versions of Impeller sometimes caused edge flickering where the texture sampler would attempt to read pixels outside the frame buffer bounds. Explicitly setting clamp forces the sampler to use the edge pixels, resolving specific flickering artifacts on the borders of widgets on iOS.
Conclusion
Impeller is a powerful engine, but it is not magic. It shifts the bottleneck from the CPU (shader compilation) to the GPU (bandwidth and pass management). When dealing with flickering or jank in Impeller:
- Identify where you are forcing offscreen passes (
BackdropFilter,Opacity,ClipPath). - Use
RepaintBoundaryto isolate these render costs. - Aggressively remove these effects during animations or scrolling using a
NotificationListener.
The most performant pixel is the one you don't render.