Last active
July 20, 2024 15:08
-
-
Save PlugFox/3a778c8cdad13ea5676b642739fc8dcc to your computer and use it in GitHub Desktop.
Animated Custom Painter
This file contains 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
/* | |
* Animated Painter | |
* https://gist.github.com/PlugFox/3a778c8cdad13ea5676b642739fc8dcc | |
* https://dartpad.dev?id=3a778c8cdad13ea5676b642739fc8dcc | |
* Mike Matiunin <plugfox@gmail.com>, 20 July 2024 | |
*/ | |
import 'dart:async'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:flutter/scheduler.dart'; | |
void main() => runZonedGuarded<void>( | |
() => runApp( | |
const MaterialApp( | |
title: 'Animated Painter', | |
debugShowCheckedModeBanner: false, | |
home: Scaffold( | |
body: SafeArea( | |
child: Padding( | |
padding: EdgeInsets.all(16), | |
child: Center( | |
child: RepaintBoundary( | |
child: AnimatedPainter( | |
size: 720, | |
velocity: 5, | |
), | |
), | |
), | |
), | |
), | |
), | |
), | |
), | |
(error, stackTrace) => | |
print('Top level exception: $error'), // ignore: avoid_print | |
); | |
class AnimatedPainter extends StatefulWidget { | |
const AnimatedPainter({ | |
this.size = 256, | |
this.velocity = 5, | |
super.key, // ignore: unused_element | |
}); | |
/// Size of the paint area | |
final double size; | |
/// Velocity of the dot | |
/// Progress (0..1) per second | |
final double velocity; | |
@override | |
State<AnimatedPainter> createState() => _AnimatedPainterState(); | |
} | |
/// State for widget AnimatedDot. | |
class _AnimatedPainterState extends State<AnimatedPainter> | |
with SingleTickerProviderStateMixin { | |
late final CustomPainter _backgroundPainter; | |
late final CustomPainter _foregroundPainter; | |
late final Ticker _ticker; | |
final ChangeNotifier _repaint = ChangeNotifier(); | |
Offset _position = const Offset(0.5, 0.5); | |
Offset _target = const Offset(0.5, 0.5); | |
double _size = 0; | |
final Paint _dotPaint = Paint() | |
..color = Colors.blue | |
..strokeWidth = 4 | |
..style = PaintingStyle.stroke; | |
final Paint _backgroundPaint = Paint() | |
..color = Colors.white | |
..style = PaintingStyle.fill; | |
final Paint _borderPaint = Paint() | |
..color = Colors.black | |
..strokeWidth = 2 | |
..style = PaintingStyle.stroke; | |
/* #region Lifecycle */ | |
@override | |
void initState() { | |
super.initState(); | |
_backgroundPainter = Painter( | |
paint: (canvas, size) => canvas | |
..drawRect( | |
Offset.zero & size, | |
_backgroundPaint, | |
) | |
..drawRect( | |
Offset.zero & size, | |
_borderPaint, | |
), | |
); | |
_foregroundPainter = Painter( | |
paint: foregroundPaint, | |
repaint: _repaint, | |
); | |
_ticker = createTicker(onTick); | |
} | |
@override | |
void didChangeDependencies() { | |
super.didChangeDependencies(); | |
final theme = Theme.of(context); | |
_dotPaint.color = theme.primaryColor; | |
_backgroundPaint.color = theme.scaffoldBackgroundColor; | |
_borderPaint.color = theme.dividerColor; | |
} | |
@override | |
void dispose() { | |
_ticker.dispose(); | |
super.dispose(); | |
} | |
/* #endregion */ | |
void foregroundPaint(Canvas canvas, Size size) { | |
canvas.drawCircle( | |
Offset( | |
_position.dx * size.width, | |
_position.dy * size.height, | |
), | |
10, | |
_dotPaint, | |
); | |
} | |
int _elapsed = 0; | |
void onTick(Duration duration) { | |
if (_size <= 0) { | |
_position = _target; | |
_ticker.stop(); | |
return; | |
} else if (duration == Duration.zero) { | |
return; | |
} | |
final elapsed = duration.inMicroseconds - _elapsed; | |
_elapsed = duration.inMicroseconds; | |
final velocity = widget.velocity * elapsed / 1e6; | |
final dx = (_target.dx - _position.dx) * velocity; | |
final dy = (_target.dy - _position.dy) * velocity; | |
final nextPosition = _position + Offset(dx, dy); | |
if ((nextPosition - _target).distanceSquared < 1e-5) { | |
_position = _target; | |
_elapsed = 0; | |
_ticker.stop(); | |
} else { | |
_position = nextPosition; | |
} | |
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member | |
_repaint.notifyListeners(); | |
} | |
void onTapDown(TapDownDetails details) { | |
if (!mounted || _size <= 0) return; | |
_target = details.localPosition / _size; | |
if (!_ticker.isTicking) _ticker.start(); | |
} | |
@override | |
Widget build(BuildContext context) => FittedBox( | |
alignment: Alignment.center, | |
fit: BoxFit.scaleDown, | |
child: Sizer( | |
onSizeChanged: (size) => _size = size.shortestSide, | |
child: GestureDetector( | |
onTapDown: onTapDown, | |
child: SizedBox.square( | |
dimension: widget.size, | |
child: CustomPaint( | |
painter: _backgroundPainter, | |
foregroundPainter: _foregroundPainter, | |
), | |
), | |
), | |
), | |
); | |
} | |
class Painter extends CustomPainter { | |
const Painter({ | |
required void Function(Canvas canvas, Size size) paint, | |
bool Function(Offset position)? hitTest, | |
super.repaint, | |
}) : _paint = paint, | |
_hitTest = hitTest; | |
final void Function(Canvas canvas, Size size) _paint; | |
final bool Function(Offset position)? _hitTest; | |
@override | |
void paint(Canvas canvas, Size size) => _paint(canvas, size); | |
@override | |
bool? hitTest(Offset position) => _hitTest?.call(position); | |
@override | |
bool shouldRepaint(covariant Painter oldDelegate) => false; | |
@override | |
bool shouldRebuildSemantics(covariant Painter oldDelegate) => false; | |
} | |
/// Measure and call callback after child size changed | |
class Sizer extends SingleChildRenderObjectWidget { | |
const Sizer({ | |
required this.onSizeChanged, | |
required Widget super.child, | |
this.dispatchNotification = false, | |
super.key, | |
}); | |
/// Callback when child size changed and after layout rebuild | |
final void Function(Size size) onSizeChanged; | |
/// Send [SizeChangedLayoutNotification] notification | |
final bool dispatchNotification; | |
@override | |
RenderObject createRenderObject(BuildContext context) => | |
_SizerRenderObject((Size size) { | |
if (dispatchNotification) { | |
const SizeChangedLayoutNotification().dispatch(context); | |
} | |
SchedulerBinding.instance | |
.addPostFrameCallback((_) => onSizeChanged(size)); | |
}); | |
} | |
class _SizerRenderObject extends RenderProxyBox { | |
_SizerRenderObject(this.onLayoutChangedCallback); | |
final void Function(Size size) onLayoutChangedCallback; | |
Size? _oldSize; | |
@override | |
void performLayout() { | |
super.performLayout(); | |
final content = child; | |
assert(content is RenderBox, 'Must contain content'); | |
assert(content!.hasSize, 'Content must obtain a size'); | |
final newSize = content!.size; | |
if (newSize == _oldSize) return; | |
_oldSize = newSize; | |
onLayoutChangedCallback(newSize); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment