Skip to content

Instantly share code, notes, and snippets.

@yeasin50
Forked from pskink/flow_painter.dart
Last active March 2, 2023 13:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save yeasin50/9456a844d87374b2dd4184dedf8c2e8a to your computer and use it in GitHub Desktop.
Save yeasin50/9456a844d87374b2dd4184dedf8c2e8a to your computer and use it in GitHub Desktop.
import 'dart:math';
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// A combination of [Flow] and [CustomPaint] widgets.
/// Both custom painting and children positioning is done in [FlowPainterDelegate].
///
/// The sequence of calling is as follows:
/// 1. [FlowPainterDelegate.paint] - paints background decorations
/// 2. [FlowPainterDelegate.paintChildren] - paints [children] widgets
/// 3. [FlowPainterDelegate.foregroundPaint] - paints foreground decorations
///
/// [FlowPaintingContext] is extended so that you can use the following [paintChild*] methods:
/// * [paintChild] (the original coming from [FlowPaintingContext] - not very easy as
/// you have to deal with raw [Matrix4] objects)
/// * [paintChildTranslated] - simplified version where only translation is used
/// * [paintChildComposedOf] - the general method where any of: scale, rotation,
/// translation and anchor can be used
class FlowPainter extends StatelessWidget {
const FlowPainter({
Key? key,
required this.delegate,
this.children = const <Widget>[],
this.wrap = true,
}) : super(key: key);
final FlowPainterDelegate delegate;
final List<Widget> children;
final bool wrap;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _PainterDelegate(delegate.paint, delegate.repaint),
foregroundPainter: _PainterDelegate(delegate.foregroundPaint, delegate.repaint),
child: Flow.unwrapped(
delegate: delegate,
children: wrap? RepaintBoundary.wrapAll(children) : children,
),
);
}
}
abstract class FlowPainterDelegate extends FlowDelegate {
const FlowPainterDelegate({ this.repaint }) : super(repaint: repaint);
final Listenable? repaint;
void paint(Canvas canvas, Size size);
void foregroundPaint(Canvas canvas, Size size);
}
extension FlowPaintingContextExtension on FlowPaintingContext {
paintChildTranslated(int i, Offset translate, { double opacity = 1.0 }) => paintChild(i,
transform: composeMatrixFromOffsets(translate: translate),
opacity: opacity,
);
/// Paints the [i]th child with a transformation.
/// The transformation is composed of [scale], [rotation], [translate] and [anchor].
///
/// [anchor] is a central point within a child where all transformations are applied:
/// 1. first the child is moved so that [anchor] point is located at [Offset.zero]
/// 2. if [scale] is provided the child is scaled by [scale] factor ([anchor] still stays at [Offset.zero])
/// 3. if [rotation] is provided the child is rotated by [rotation] radians ([anchor] still stays at [Offset.zero])
/// 4. finally if [translate] is provided the child is moved by [translation]
///
/// For example if child size is `Size(80, 60)` and for
/// `anchor: Offset(20, 10), scale: 2, translate: Offset(100, 100)` then child's
/// top-left and bottom-right corners are as follows:
///
/// **step** | **top-left** | **bottom-right**
/// ----------------------------------------------
/// 1. | Offset(-20, -10) | Offset(60, 50)
/// 2. | Offset(-40, -20) | Offset(120, 100)
/// 3. | n/a | n/a
/// 4. | Offset(60, 80) | Offset(220, 200)
///
/// The following image shows how it works in practice:
///
/// ![](https://gist.githubusercontent.com/pskink/10c8fd372113f0b36570db96d58d818e/raw/bcad78911eee26e80fc5bd1f70928f36d620f813/transform_composing.png)
///
/// steps:
/// 1. `anchor: Offset(61, 49)` - indicated by a green vector
/// 2. `scale: 1.2` - the anchor point is untouched after scaling
/// 3. `rotation: 0.1 * pi` - the anchor point is untouched after rotating
/// 4. `translate: Offset(24, -71)` - indicated by a red vector
paintChildComposedOf(int i, {
double scale = 1,
double rotation = 0,
Offset translate = Offset.zero,
Offset anchor = Offset.zero,
double opacity = 1.0,
}) => paintChild(i,
transform: composeMatrixFromOffsets(
scale: scale,
rotation: rotation,
translate: translate,
anchor: anchor,
),
opacity: opacity,
);
Matrix4 composeMatrixFromOffsets({
double scale = 1,
double rotation = 0,
Offset translate = Offset.zero,
Offset anchor = Offset.zero,
}) {
if (rotation == 0) {
return Matrix4(
scale, 0, 0, 0,
0, scale, 0, 0,
0, 0, 1, 0,
translate.dx - scale * anchor.dx, translate.dy - scale * anchor.dy, 0, 1
);
}
final double c = cos(rotation) * scale;
final double s = sin(rotation) * scale;
final double dx = translate.dx - c * anchor.dx + s * anchor.dy;
final double dy = translate.dy - s * anchor.dx - c * anchor.dy;
return Matrix4(c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, dx, dy, 0, 1);
}
}
class _PainterDelegate extends CustomPainter {
final void Function(Canvas, Size) paintDelegate;
_PainterDelegate(this.paintDelegate, Listenable? repaint) : super(repaint: repaint);
@override
void paint(Canvas canvas, Size size) => paintDelegate(canvas, size);
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
// =============================================================================
//
// example
//
// the most important parts of the code is:
//
// ❶ - super(repaint: _animationController) it means that paintChildren() method
// will be called whenever _animationController notifies its listeners
// ❷ - paintChildComposedOf() called to position each child on the curve
// ❸ - canvas.drawCircle() called twice to draw styled "sunken circle"
// ❹ - the loop to mimic the motion blur effect
// ❺ - code to draw up to 5 motion blur shadows
main() {
runApp(MaterialApp(
home: Scaffold(
body: MotionBlurAnimation(),
),
));
}
class MotionBlurAnimation extends StatefulWidget {
@override
_MotionBlurAnimationState createState() => _MotionBlurAnimationState();
}
class _MotionBlurAnimationState extends State<MotionBlurAnimation> with TickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
late final List<Item> _items;
@override
void initState() {
super.initState();
const icons = [Icons.favorite, Icons.message, Icons.wb_sunny, Icons.wb_cloudy, Icons.account_circle];
const colors = [Color(0xffaa0000), Color(0xff00aa00), Colors.orange, Color(0xff0000aa), Colors.deepPurple];
_items = List.generate(icons.length, (i) => Item(
icon: icons[i],
color: Color.alphaBlend(Colors.black12, colors[i]),
animation: CurvedAnimation(
parent: _controller,
curve: Interval(i / 10, (10 - colors.length + 1 + i) / 10, curve: Curves.decelerate),
)
));
}
@override
Widget build(BuildContext context) {
print(MediaQuery.of(context).size);
return ColoredBox(
color: Colors.black26,
child: FlowPainter(
delegate: MotionBlurAnimationDelegate(_controller, _items),
children: [
DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
colors: [Colors.white60, Colors.white.withOpacity(0)],
center: const Alignment(-.5, -.25),
radius: 0.3,
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Material(
type: MaterialType.transparency,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
highlightColor: Colors.transparent,
splashColor: Colors.white60,
onTap: () => _controller.value < 0.5? _controller.forward() : _controller.reverse(),
child: const Center(child: Text('click to start the motion blur animation', textScaleFactor: 1.5, textAlign: TextAlign.center)),
),
),
),
),
... _items.mapIndexed((index, item) =>
IconButton(
onPressed: () => print('button #$index clicked'),
icon: Icon(item.icon),
iconSize: 64,
color: item.color,
),
),
],
),
);
}
}
class MotionBlurAnimationDelegate extends FlowPainterDelegate {
MotionBlurAnimationDelegate(this.controller, this.items) :
super(repaint: controller); // ❶
final AnimationController controller;
final List<Item> items;
bool? init;
late Rect circleRect;
final _shadowPaint = Paint()
// NOTE you can remove maskFilter if things go laggy
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
final _circlePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2
..color = Colors.black26;
@override
void paint(Canvas canvas, Size size) {
// ❹
for (final item in items) {
final Set drawn = {};
final double value = item.animation.value * item.distance;
final int sign = item.animation.status == AnimationStatus.forward? -1 : 1;
final factor = sin(item.animation.value * pi);
_shadowPaint.color = item.color.withOpacity(factor * 0.33);
for (int i = 0; i < 5 ; i++) {
final double d = (value + sign * i * 32 * factor).clamp(0.0, item.distance);
if (drawn.add(d.round())) {
final offset = getTangent(item.metrics, d).position;
canvas.drawCircle(offset, 32, _shadowPaint); // ❺
}
}
}
}
@override
void foregroundPaint(Canvas canvas, Size size) {
final r = circleRect;
// ❸
canvas.drawCircle(r.center + const Offset(2, 2), r.shortestSide / 2, _circlePaint..color = Colors.white30);
canvas.drawCircle(r.center, r.shortestSide / 2, _circlePaint..color = Colors.black26);
}
@override
void paintChildren(FlowPaintingContext context) {
// timeDilation = 5;
int i = 0;
// paint padded, centered label
context.paintChildTranslated(i++, circleRect.topLeft,
opacity: 1 - sin(pi * controller.value),
);
for (final item in items) {
final distance = item.animation.value * item.distance;
// ❷
context.paintChildComposedOf(i,
anchor: context.getChildSize(i)!.center(Offset.zero),
translate: getTangent(item.metrics, distance).position,
opacity: sin(item.animation.value * pi / 2),
);
i++;
}
}
bool initPaths(Size size) {
const pad = 64 / 2;
circleRect = Alignment.topCenter.inscribe(Size.square(size.shortestSide - 64), Offset.zero & size)
.translate(0, pad);
final r = circleRect;
final cnt = items.length;
items.forEachIndexed((index, item) {
final x = lerpDouble(pad, size.width - pad, index / (cnt - 1))!;
final path = Path()
..moveTo(size.width - x, size.height)
..quadraticBezierTo(x, r.center.dy, r.topCenter.dx, r.topCenter.dy)
..addArc(r, -pi / 2, pi * 2);
item.metrics = path.computeMetrics().toList();
item.distance = item.metrics[0].length + item.metrics[1].length * ((cnt - index - 1) / cnt);
});
return true;
}
@override
Size getSize(BoxConstraints constraints) {
init ??= initPaths(constraints.biggest);
return super.getSize(constraints);
}
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return i == 0?
// set label's constraints
constraints.tighten(width: circleRect.width, height: circleRect.height) :
super.getConstraintsForChild(i, constraints);
}
@override
bool shouldRepaint(covariant FlowDelegate oldDelegate) => false;
}
class Item {
Item({
required this.icon,
required this.color,
required this.animation,
});
final IconData icon;
final Color color;
final Animation<double> animation;
late double distance;
late List<PathMetric> metrics;
}
Tangent getTangent(List<PathMetric> metrics, double distance) {
var tmp = 0.0;
return metrics.firstWhere((e) {
if (distance <= tmp + e.length || e == metrics.last) return true;
tmp += e.length;
return false;
}).getTangentForOffset(distance - tmp)!;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment