-
-
Save yeasin50/9456a844d87374b2dd4184dedf8c2e8a to your computer and use it in GitHub Desktop.
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
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: | |
/// | |
///  | |
/// | |
/// 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