Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active July 20, 2024 15:08
Show Gist options
  • Save PlugFox/3a778c8cdad13ea5676b642739fc8dcc to your computer and use it in GitHub Desktop.
Save PlugFox/3a778c8cdad13ea5676b642739fc8dcc to your computer and use it in GitHub Desktop.
Animated Custom Painter
/*
* 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