Last active
September 24, 2026 16:22
-
-
Save pskink/340a4cb43624218b9ec4f53ac4b518c6 to your computer and use it in GitHub Desktop.
Flutter FragmentShaderFiltered widget with reactive ValueListenable support
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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