diff --git a/CHANGELOG.md b/CHANGELOG.md index d608d09..ca0aaab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 0.2.5 + +* Improve `WaveWidget` paint performance for long-duration, high-amplitude + wave configurations like GitHub issue #53. +* Add a manual benchmark for the issue #53 scenario and regression coverage for + runtime amplitude updates. + ## 0.2.4 * Expand Dart SDK compatibility to allow Dart 3. diff --git a/lib/src/widget.dart b/lib/src/widget.dart index af31065..2f17f1c 100644 --- a/lib/src/widget.dart +++ b/lib/src/widget.dart @@ -349,6 +349,20 @@ class _WaveWidgetState extends State with TickerProviderStateMixin { throw FlutterError('Unsupported or missing `ColorMode` in `config`.'); } + List _animationDurations(Config config) { + if (config.colorMode == ColorMode.custom) { + return (config as CustomConfig).durations!; + } + if (config.colorMode == ColorMode.random) { + return (config as RandomConfig).durations; + } + if (config.colorMode == ColorMode.single) { + return (config as SingleConfig).durations; + } + + throw FlutterError('Unsupported or missing `ColorMode` in `config`.'); + } + Color _colorWithOpacity(Color color, double opacity) { return color.withAlpha((opacity * 255).round()); } @@ -396,11 +410,15 @@ class _WaveWidgetState extends State with TickerProviderStateMixin { } bool _shouldRecreateAnimations(WaveWidget oldWidget) { - return oldWidget.config != widget.config || - oldWidget.waveAmplitude != widget.waveAmplitude || - oldWidget.wavePhase != widget.wavePhase || + if (oldWidget.wavePhase != widget.wavePhase || oldWidget.isLoop != widget.isLoop || - oldWidget.duration != widget.duration; + oldWidget.duration != widget.duration) { + return true; + } + + final oldDurations = _animationDurations(oldWidget.config); + final durations = _animationDurations(widget.config); + return !_listEquals(oldDurations, durations); } @override @@ -426,16 +444,26 @@ class _WaveWidgetState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: widget.backgroundColor, - image: widget.backgroundImage, - ), - child: Stack( - children: _buildPaints(), + return RepaintBoundary( + child: Container( + decoration: BoxDecoration( + color: widget.backgroundColor, + image: widget.backgroundImage, + ), + child: Stack( + children: _buildPaints(), + ), ), ); } + + bool _listEquals(List first, List second) { + if (first.length != second.length) return false; + for (int i = 0; i < first.length; i++) { + if (first[i] != second[i]) return false; + } + return true; + } } class _WaveLayerSpec { @@ -490,6 +518,10 @@ class Layer { } class _CustomWavePainter extends CustomPainter { + static const int _minWaveSamples = 96; + static const int _samplesPerFrequencyUnit = 48; + static const double _targetWaveSampleWidth = 4.0; + final Color? color; final List? gradient; final Alignment? gradientBegin; @@ -501,6 +533,10 @@ class _CustomWavePainter extends CustomPainter { final double heightPercentage; final Paint _paint = Paint(); + final Path _path = Path(); + Shader? _cachedGradientShader; + Size? _cachedGradientSize; + double? _cachedGradientCenterY; _CustomWavePainter({ this.color, @@ -515,49 +551,38 @@ class _CustomWavePainter extends CustomPainter { Listenable? repaint, }) : super(repaint: repaint); - void _setPaths(double viewCenterY, Size size, Canvas canvas) { - final layer = Layer( - path: Path(), - color: color, - gradient: gradient, - blur: blur, - amplitude: -0.8 * waveAmplitude, - phase: wavePhaseValue.value * 2 + 30, - ); - - layer.path!.reset(); - layer.path!.moveTo( + void _paintWave(double viewCenterY, Size size, Canvas canvas) { + final amplitude = -0.8 * waveAmplitude; + final phase = wavePhaseValue.value * 2 + 30; + final path = _path..reset(); + path.moveTo( 0.0, - viewCenterY + - layer.amplitude! * _getSinY(layer.phase!, waveFrequency, -1, size), + viewCenterY + amplitude * _getSinY(phase, -1, size), ); - for (int i = 1; i < size.width + 1; i++) { - layer.path!.lineTo( - i.toDouble(), - viewCenterY + - layer.amplitude! * _getSinY(layer.phase!, waveFrequency, i, size), + + final sampleCount = _sampleCountForWidth(size.width); + final sampleWidth = size.width / sampleCount; + for (int i = 1; i <= sampleCount; i++) { + final x = i == sampleCount ? size.width : sampleWidth * i; + path.lineTo( + x, + viewCenterY + amplitude * _getSinY(phase, x, size), ); } - layer.path!.lineTo(size.width, size.height); - layer.path!.lineTo(0.0, size.height); - layer.path!.close(); - if (layer.color != null) { - _paint.color = layer.color!; + path.lineTo(size.width, size.height); + path.lineTo(0.0, size.height); + path.close(); + + if (color != null) { + _paint.color = color!; _paint.shader = null; + } else if (gradient != null) { + _paint.shader = _gradientShader(size, viewCenterY); } - if (layer.gradient != null) { - final rect = Offset.zero & - Size(size.width, size.height - viewCenterY * heightPercentage); - _paint.shader = LinearGradient( - begin: gradientBegin ?? Alignment.bottomCenter, - end: gradientEnd ?? Alignment.topCenter, - colors: layer.gradient!, - ).createShader(rect); - } - _paint.maskFilter = layer.blur; + _paint.maskFilter = blur; _paint.style = PaintingStyle.fill; - canvas.drawPath(layer.path!, _paint); + canvas.drawPath(path, _paint); } @override @@ -566,7 +591,7 @@ class _CustomWavePainter extends CustomPainter { return; } final viewCenterY = size.height * (heightPercentage + 0.1); - _setPaths(viewCenterY, size, canvas); + _paintWave(viewCenterY, size, canvas); } @override @@ -577,14 +602,14 @@ class _CustomWavePainter extends CustomPainter { oldDelegate.gradientEnd != gradientEnd || oldDelegate.blur != blur || oldDelegate.waveAmplitude != waveAmplitude || + oldDelegate.wavePhaseValue != wavePhaseValue || oldDelegate.waveFrequency != waveFrequency || oldDelegate.heightPercentage != heightPercentage; } double _getSinY( double startRadius, - double waveFrequency, - int currentPosition, + double currentPosition, Size size, ) { final scale = pi / size.width; @@ -595,6 +620,35 @@ class _CustomWavePainter extends CustomPainter { ); } + int _sampleCountForWidth(double width) { + final maxSamples = max(1, width.ceil()); + final widthSamples = (width / _targetWaveSampleWidth).ceil(); + final frequencySamples = + (waveFrequency.abs() * _samplesPerFrequencyUnit).ceil(); + final requestedSamples = + max(_minWaveSamples, max(widthSamples, frequencySamples)); + return min(maxSamples, requestedSamples); + } + + Shader _gradientShader(Size size, double viewCenterY) { + if (_cachedGradientShader != null && + _cachedGradientSize == size && + _cachedGradientCenterY == viewCenterY) { + return _cachedGradientShader!; + } + + final shaderHeight = max(0.0, size.height - viewCenterY * heightPercentage); + final rect = Offset.zero & Size(size.width, shaderHeight); + _cachedGradientShader = LinearGradient( + begin: gradientBegin ?? Alignment.bottomCenter, + end: gradientEnd ?? Alignment.topCenter, + colors: gradient!, + ).createShader(rect); + _cachedGradientSize = size; + _cachedGradientCenterY = viewCenterY; + return _cachedGradientShader!; + } + bool _listEquals(List? first, List? second) { if (first == null) return second == null; if (second == null || first.length != second.length) return false; diff --git a/pubspec.yaml b/pubspec.yaml index 84d968f..3a2bb14 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: wave description: Widget for displaying waves with custom color, duration, floating and blur effects. -version: 0.2.4 +version: 0.2.5 homepage: https://github.com/glorylab/wave environment: diff --git a/test/wave_test.dart b/test/wave_test.dart index 614aa16..de318f4 100644 --- a/test/wave_test.dart +++ b/test/wave_test.dart @@ -70,6 +70,38 @@ void main() { expect(tester.takeException(), isNull); }); + + testWidgets('renders issue 53 long-duration high-amplitude waves', + (WidgetTester tester) async { + await tester.pumpWidget(getWaveWidget( + config: issue53Config(), + waveAmplitude: 180, + waveFrequency: 1, + )); + + for (int i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + expect(tester.takeException(), isNull); + }); + + testWidgets('updates wave amplitude at runtime', + (WidgetTester tester) async { + await tester.pumpWidget(getWaveWidget( + config: SingleConfig(), + waveAmplitude: 8, + )); + await tester.pump(const Duration(milliseconds: 16)); + + await tester.pumpWidget(getWaveWidget( + config: SingleConfig(), + waveAmplitude: 24, + )); + await tester.pump(const Duration(milliseconds: 16)); + + expect(tester.takeException(), isNull); + }); }); } @@ -77,9 +109,13 @@ Widget getWaveWidget({ Config? config, int? duration, bool isLoop = true, + double waveAmplitude = 5.0, + double waveFrequency = 1.6, }) { return MaterialApp( - home: Container( + home: SizedBox( + width: 1440, + height: 300, child: WaveWidget( backgroundColor: Colors.white, config: config ?? @@ -102,8 +138,24 @@ Widget getWaveWidget({ double.infinity, double.infinity, ), - waveAmplitude: 5.0, + waveAmplitude: waveAmplitude, + waveFrequency: waveFrequency, ), ), ); } + +Config issue53Config() { + return CustomConfig( + gradients: [ + [Colors.white, Colors.white, Colors.white], + const [Color(0xFF3EA894), Color(0xFF00BAB9), Color(0xFF42B58D)], + const [Color(0xFFBEFED2), Color(0xFF39DBB1), Color(0xFF00CDA3)], + const [Color(0xFF3EA894), Color(0xFF00BAB9), Color(0xFF42B58D)], + ], + durations: [43000, 43000, 45000, 45000], + heightPercentages: [0.55, 0.552, 0.90, 0.91], + gradientBegin: Alignment.centerRight, + gradientEnd: Alignment.centerLeft, + ); +} diff --git a/tool/issue_53_benchmark_test.dart b/tool/issue_53_benchmark_test.dart new file mode 100644 index 0000000..1cbd75f --- /dev/null +++ b/tool/issue_53_benchmark_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wave/wave.dart'; + +void main() { + testWidgets('issue 53 frame pump benchmark', (WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(1440, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 1440, + height: 300, + child: WaveWidget( + config: CustomConfig( + gradients: [ + [Colors.white, Colors.white, Colors.white], + const [ + Color(0xFF3EA894), + Color(0xFF00BAB9), + Color(0xFF42B58D), + ], + const [ + Color(0xFFBEFED2), + Color(0xFF39DBB1), + Color(0xFF00CDA3), + ], + const [ + Color(0xFF3EA894), + Color(0xFF00BAB9), + Color(0xFF42B58D), + ], + ], + durations: [43000, 43000, 45000, 45000], + heightPercentages: [0.55, 0.552, 0.90, 0.91], + gradientBegin: Alignment.centerRight, + gradientEnd: Alignment.centerLeft, + ), + size: const Size(double.infinity, 300), + waveFrequency: 1, + waveAmplitude: 180, + backgroundColor: Colors.transparent, + ), + ), + ), + ), + ); + + await tester.pump(); + + const frames = 1800; + final stopwatch = Stopwatch()..start(); + for (int i = 0; i < frames; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + stopwatch.stop(); + + final averageFrameMs = stopwatch.elapsedMicroseconds / frames / 1000; + // This file is a manual benchmark. Do not assert on timing because CI + // runners and local machines vary substantially. + // ignore: avoid_print + print( + 'issue_53_benchmark frames=$frames ' + 'elapsed_ms=${stopwatch.elapsedMilliseconds} ' + 'average_frame_ms=${averageFrameMs.toStringAsFixed(3)}', + ); + + expect(tester.takeException(), isNull); + }); +}