Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active September 24, 2026 16:22
Show Gist options
  • Select an option

  • Save pskink/340a4cb43624218b9ec4f53ac4b518c6 to your computer and use it in GitHub Desktop.

Select an option

Save pskink/340a4cb43624218b9ec4f53ac4b518c6 to your computer and use it in GitHub Desktop.
Flutter FragmentShaderFiltered widget with reactive ValueListenable support
import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// Signature for a callback that updates a [FragmentShader] based on the child's
/// [Size] and a reactive [value] from a [ValueListenable].
typedef ShaderUpdateCallback<T> = Function(Size size, FragmentShader shader, T value);
/// A widget that applies a [FragmentShader] to its child and automatically updates
/// the shader parameters whenever a [ValueListenable] notifies its listeners.
///
/// The [updateCallback] is executed during the compositing phase, providing access
/// to the actual [Size] of the child widget, the target [FragmentShader], and the
/// current value of the [listenable].
///
/// {@tool snippet}
/// This example demonstrates how to pass touch coordinates and widget dimensions
/// to a fragment shader filter:
///
/// ```dart
/// FragmentShaderFiltered<Offset>(
/// shader: myFragmentShader,
/// listenable: touchOffsetNotifier,
/// updateCallback: (size, shader, offset) {
/// final hAlign = 2 * (offset.dx / size.width) - 1;
/// final vAlign = 2 * (offset.dy / size.height) - 1;
/// shader.getUniformFloat('hAlign', 0).set(hAlign);
/// shader.getUniformFloat('vAlign', 0).set(vAlign);
/// },
/// child: const Text('Filtered Text'),
/// )
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [ImageFiltered], which applies an [ImageFilter] to a child widget.
/// * [FragmentShader], which represents a GLSL/AGSL fragment shader.
/// * [ValueListenable], which provides reactive notifications when its value changes.
@immutable
class FragmentShaderFiltered<T>({
super.key,
/// The shader to apply to the child of this widget.
required final FragmentShader shader,
/// The listenable that triggers the shader to update when its value changes.
required final ValueListenable<T> listenable,
/// A callback invoked to update the [shader] using the widget's [Size] and
/// the current value of [listenable].
required final ShaderUpdateCallback<T> updateCallback,
/// Whether or not to apply the image filter operation to the child of this
/// widget.
///
/// Prefer setting enabled to `false` instead of creating a "no-op" filter
/// type for performance reasons.
final bool enabled = true,
super.child,
}) extends SingleChildRenderObjectWidget {
@override
RenderObject createRenderObject(BuildContext context) =>
_FragmentShaderRenderObject<T>(shader, listenable, updateCallback, enabled);
@override
void updateRenderObject(BuildContext context, RenderObject renderObject) {
(renderObject as _FragmentShaderRenderObject<T>)
..enabled = enabled
..listenable = listenable
..updateCallback = updateCallback
..shader = shader;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<FragmentShader>('shader', shader))
..add(DiagnosticsProperty<ValueListenable<T>>('listenable', listenable))
..add(FlagProperty('enabled', value: enabled));
}
}
class _FragmentShaderRenderObject<T> extends RenderProxyBox {
_FragmentShaderRenderObject(this._shader, this._listenable, this._updateCallback, this._enabled);
_listener() {
if (enabled) markNeedsPaint();
}
ValueListenable<T> _listenable;
set listenable(ValueListenable<T> value) {
if (_listenable == value) {
return;
}
_listenable.removeListener(_listener);
_listenable = value;
_listenable.addListener(_listener);
}
ShaderUpdateCallback<T> _updateCallback;
set updateCallback(ShaderUpdateCallback<T> value) => _updateCallback = value;
@override
attach(PipelineOwner owner) {
super.attach(owner);
_listenable.addListener(_listener);
}
@override
detach() {
super.detach();
_listenable.removeListener(_listener);
}
bool get enabled => _enabled;
bool _enabled;
set enabled(bool value) {
if (enabled == value) {
return;
}
final bool wasRepaintBoundary = isRepaintBoundary;
_enabled = value;
if (isRepaintBoundary != wasRepaintBoundary) {
markNeedsCompositingBitsUpdate();
}
markNeedsPaint();
}
FragmentShader get shader => _shader;
FragmentShader _shader;
set shader(FragmentShader value) {
if (value != _shader) {
_shader = value;
markNeedsCompositedLayerUpdate();
}
}
@override
bool get alwaysNeedsCompositing => child != null && enabled;
@override
bool get isRepaintBoundary => alwaysNeedsCompositing;
@override
OffsetLayer updateCompositedLayer({required covariant ImageFilterLayer? oldLayer}) {
final ImageFilterLayer layer = oldLayer ?? ImageFilterLayer();
_updateCallback(size, _shader, _listenable.value);
layer.imageFilter = ImageFilter.shader(_shader);
return layer;
}
}
// ===============================================================
// **NOTE**
//
// To run this test example:
// 1. Add the attached test.frag file to `shaders/test.frag`.
// 2. Update `pubspec.yaml` to include:
//
// flutter:
// shaders:
// - shaders/test.frag
// ===============================================================
main() {
runApp(
MaterialApp(
home: const TestFragmentShaderFiltered(
path: 'shaders/test.frag'
)
)
);
}
class const TestFragmentShaderFiltered({super.key, required final String path}) extends StatefulWidget {
@override
State<TestFragmentShaderFiltered> createState() => _TestFragmentShaderFilteredState();
}
class _TestFragmentShaderFilteredState extends State<TestFragmentShaderFiltered> {
FragmentShader? _shader;
bool _enabled = true;
final _listenable = ValueNotifier(Offset.zero);
final _controller = ScrollController();
bool _isScrollAnimationRunning = false;
String? _error;
@override
void initState() {
super.initState();
_loadShader();
}
Future<void> _loadShader() async {
if (!ImageFilter.isShaderFilterSupported) {
setState(() => _error = '''ImageFilter.isShaderFilterSupported == false
This API is only supported when using the Impeller rendering engine.
On other backends, an [UnsupportedError] will be thrown.
''');
return;
}
try {
final program = await FragmentProgram.fromAsset(widget.path);
if (!mounted) {
return;
}
setState(() {
_shader = program.fragmentShader();
});
} catch (e) {
setState(() => _error = e.toString());
}
}
@override
Widget build(BuildContext context) {
final fragmentShader = _shader;
return Scaffold(
body: Center(
child: Builder(
builder: (context) {
if (fragmentShader == null) {
return _error == null?
CircularProgressIndicator() :
Text(_error!);
}
return Column(
mainAxisSize: .min,
children: [
ColoredBox(
color: Colors.green.shade100,
child: Padding(
padding: const EdgeInsets.all(6),
child: Text('Drag your finger over the image to see the shader in action.'),
),
),
CheckboxListTile(
title: Text('enabled'),
value: _enabled,
onChanged: (bool? value) {
setState(() {
_enabled = value ?? true;
});
},
),
NotificationListener<ScrollMetricsNotification>(
onNotification: _startAnimating,
child: GestureDetector(
onPanDown: (d) => _listenable.value = d.localPosition,
onPanUpdate: (d) => _listenable.value = d.localPosition,
child: FragmentShaderFiltered(
shader: fragmentShader,
listenable: _listenable,
updateCallback: (size, shader, offset) {
final hAlign = 2 * (offset.dx / size.width) - 1;
final vAlign = 2 * (offset.dy / size.height) - 1;
shader.getUniformFloat('hAlign', 0).set(hAlign);
shader.getUniformFloat('vAlign', 0).set(vAlign);
final w2 = size.width / 2;
final r = lerpDouble(0.33, 1, (offset.dx - w2).abs() / w2)!;
shader.getUniformFloat('fullDesaturationRadius', 0).set(r);
},
enabled: _enabled,
child: ChildToFilter(controller: _controller),
),
),
),
],
);
}
),
),
);
}
bool _startAnimating(ScrollMetricsNotification notification) {
if (_isScrollAnimationRunning || notification.metrics.maxScrollExtent <= 0) {
return false;
}
unawaited(_animateScrollLoop());
return false;
}
Future<void> _animateScrollLoop() async {
_isScrollAnimationRunning = true;
try {
int cnt = 0;
final pos = _controller.position;
while (mounted && _controller.hasClients && cnt < 20) {
if (pos.maxScrollExtent <= 0) {
break;
}
await _controller.animateTo(
cnt.isEven? pos.maxScrollExtent : pos.minScrollExtent,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 1500),
);
if (!mounted || !_controller.hasClients) break;
await Future.delayed(const Duration(seconds: 6));
if (!mounted || !_controller.hasClients) break;
cnt++;
}
} finally {
_isScrollAnimationRunning = false;
}
}
@override
void dispose() {
_controller.dispose();
_listenable.dispose();
super.dispose();
}
}
class ChildToFilter({
required final ScrollController controller,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.teal.shade900,
Colors.teal.shade400,
],
),
),
child: Column(
mainAxisSize: .min,
children: [
SingleChildScrollView(
scrollDirection: .horizontal,
controller: controller,
child: Row(
children: [
for (final hue in <double>[240, 180, 120, 60, 30, 0])
ColoredFlutterLogo(hue: hue),
],
),
),
Text(
'widget filtered by FragmentShaderFiltered',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.orange),
textAlign: .center,
),
],
),
);
}
}
class ColoredFlutterLogo({super.key, required final double hue}) extends StatelessWidget {
static final _layers = [
'UklGRrQAAABXRUJQVlA4TKgAAAAvY8AeEGDktm0k/v/fs6VuJnOaCEaRJCkqWfvj3WNmJv8azkDa'
'Nn/DFdwE1q7RX7NkewxFJHMibY9+mmTt0U6TvD2alyDpg6I9omlSToDQB9WQ8X1QDxk3TZod2NZi'
'p8neHmaaHO2hPxjbDpxrUXMCok78cQHrK5FQgYzqxH6/B8jpkaQeyOq2tHYAMLckdkNml/LzArnt'
'AGBPZHeQnw/IbwcAfv+n7wE=',
'UklGRsAAAABXRUJQVlA4TLQAAAAvY8AeEHDgtm0k8f/PnnvSpO3sGeGstm1T2++fExg9bKWyP0Vw'
'DmSyGLhtG8c2d+8jUKJ///PtELptTNltQ/XyD6liBpA8Rko1BdwLIA6VegaQPSbzD00F2u4hzBOi'
'RzeuTY25xbDuHnwUxBjYzABkECdoXH5InLHQfEHarwIAQYAcuy3Lbko/l0Wm3ZBrFQAIL8q3SzLu'
'gpyrAEB0BAd5K97FdZF5FQDId7Jvlb8MLHxQFwA=',
'UklGRr4AAABXRUJQVlA4TLIAAAAvY8AeEHDcSJIihf9e7+7BQO7vIIJtJNtNnkLhIbWlkHnogJ4g'
'hHZMTUjZGUzaZjWwW+gW+/t/v8NPwdzgtLy7T80BhxsEuJh8j+I9S/dKuDzVmwAnc4MAR4+aB0YE'
'/yG9BtUa1mpCqTyVH5hUKU95lsdyWDL7bvol5xHRdovab0l53rOG2SozTNYxVclELcPVjNWb8X1i'
'ojzfWdO7qrO+rjvby8pe1PZU3VV/OhF9UI8A',
'UklGRowAAABXRUJQVlA4TH8AAAAvY8AeEGDYNpIi91/2M8w+QwSjSJLbiIQACEf45Swn/hx8AIZg'
'kmazZ6gS//3f7//ugzEVxhMnfxwB7Ao18utIrye7ARUCrNxGpDaqQoAZlx+YJK94bNNkWc1QIcDI'
'aaaUZssoHk2wyMW1ubHM9ePAsyW0tXlPHHVtvt0l1CAAAA==',
];
static final _cache = <int, Uint8List>{};
@override
Widget build(BuildContext context) {
const kTargetHeight = 124.0;
return Padding(
padding: const EdgeInsets.all(8.0),
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(top: 3, left: 3),
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
child: Stack(
children: [
for (final i in Iterable.generate(_layers.length))
Image.memory(
_decodeLayer(i),
height: kTargetHeight,
color: Colors.black87,
),
],
),
),
),
for (final i in Iterable.generate(_layers.length))
Image.memory(
_decodeLayer(i),
height: kTargetHeight,
color: _color(i),
),
],
),
);
}
Uint8List _decodeLayer(i) {
return _cache.putIfAbsent(i, () => base64.decode(_layers[i]));
}
Color _color(i) {
final (a, h, s, v) = switch (i) {
0 || 1 => (1.0, hue, 0.66, 0.97),
2 => (0.5, hue, 0.66, 0.5),
3 => (1.0, (hue + 15) % 360, 1.0, 0.7),
_ => throw 'unknown layer index [$i]'
};
return HSVColor.fromAHSV(a, h, s, v).toColor();
}
}
#include <flutter/runtime_effect.glsl>
// `ImageFilter.shader` requires its first uniform to be the filter size,
// followed by a sampler for the input image.
uniform vec2 uSize;
uniform sampler2D uTexture;
uniform float hAlign;
uniform float vAlign;
// Fraction of the circle radius that is fully desaturated (0.0 to 1.0).
uniform float fullDesaturationRadius;
out vec4 fragColor;
void main() {
vec2 st = FlutterFragCoord().xy / uSize;
vec4 color = texture(uTexture, st);
// Map alignment from [-1, 1] to [0, uSize]
vec2 align = vec2(hAlign, vAlign);
vec2 center = (align + 1.0) * 0.5 * uSize;
float radius = min(uSize.x, uSize.y) * 0.5;
float distanceFromCenter = length(FlutterFragCoord().xy - center);
float fullyDesaturatedRadius = radius * clamp(fullDesaturationRadius, 0.0, 1.0);
float grayscaleMask;
if (fullyDesaturatedRadius < radius) {
// Full grayscale from the center to `fullyDesaturatedRadius`, then
// a continuous transition back to the original color at the circle edge.
grayscaleMask = 1.0 - smoothstep(
fullyDesaturatedRadius,
radius,
distanceFromCenter
);
} else {
// Avoid smoothstep with identical edges when the parameter is 1.0.
grayscaleMask = 1.0 - smoothstep(
radius - 1.0,
radius + 1.0,
distanceFromCenter
);
}
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
vec3 filteredColor = mix(color.rgb, vec3(gray), grayscaleMask);
fragColor = vec4(filteredColor, color.a);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment