Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active March 9, 2024 12:47
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save pskink/10c8fd372113f0b36570db96d58d818e to your computer and use it in GitHub Desktop.
Save pskink/10c8fd372113f0b36570db96d58d818e 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';
import 'flow_painting_context_extension.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:
/// * [FlowPaintingContext.paintChild] (the original coming from [FlowPaintingContext] - not very easy as
/// you have to deal with raw [Matrix4] objects)
/// * [FlowPaintingContextExtension.paintChildTranslated] - simplified version where only translation is used
/// * [FlowPaintingContextExtension.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,
this.clipBehavior = Clip.hardEdge,
}) : super(key: key);
final FlowPainterDelegate delegate;
final List<Widget> children;
final bool wrap;
final Clip clipBehavior;
@override
Widget build(BuildContext context) {
final flow = wrap? Flow(
clipBehavior: clipBehavior,
delegate: delegate,
children: children,
) : Flow.unwrapped(
clipBehavior: clipBehavior,
delegate: delegate,
children: children,
);
return CustomPaint(
painter: _PainterDelegate(delegate.paint, delegate.repaint),
foregroundPainter: _PainterDelegate(delegate.foregroundPaint, delegate.repaint),
child: flow,
);
}
}
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);
}
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;
}
// =============================================================================
//
// examples
//
main() {
runApp(MaterialApp(
scrollBehavior: const MaterialScrollBehavior().copyWith(
dragDevices: {PointerDeviceKind.mouse, PointerDeviceKind.touch},
),
routes: {
'motionBlurAnimation': (ctx) => Scaffold(appBar: AppBar(), body: MotionBlurAnimation()),
'rotatingGrid': (ctx) => Scaffold(appBar: AppBar(), body: RotatingGrid()),
'clock': (ctx) => Scaffold(appBar: AppBar(), body: Clock()),
'split': (ctx) => Scaffold(appBar: AppBar(), body: Split()),
},
home: Scaffold(
body: Builder(
builder: (context) {
return ListView(
children: [
ListTile(
leading: const Icon(Icons.blur_linear),
title: const Text('1. MotionBlurAnimation'),
onTap: () => Navigator.of(context).pushNamed('motionBlurAnimation'),
),
ListTile(
leading: const Icon(Icons.grid_view),
title: const Text('2. RotatingGrid'),
subtitle: const Text('tap on any card to rotate the grid'),
onTap: () => Navigator.of(context).pushNamed('rotatingGrid'),
),
ListTile(
leading: const Icon(Icons.alarm),
title: const Text('3. Clock'),
onTap: () => Navigator.of(context).pushNamed('clock'),
),
ListTile(
leading: const Icon(Icons.splitscreen),
title: const Text('4. Split'),
subtitle: const Text('scroll the content to the left and right'),
onTap: () => Navigator.of(context).pushNamed('split'),
),
],
);
}
),
),
));
}
// the most important parts of the code is:
//
// ❶ - super(repaint: controller) - paintChildren() method will be called
// whenever `controller` 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
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: () {
final snackBar = SnackBar(
content: Text('button #$index clicked'),
duration: const Duration(milliseconds: 1000),
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
},
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)!;
}
// -----------------------------------------------------------------------------
class RotatingGrid extends StatefulWidget {
@override
State<RotatingGrid> createState() => _RotatingGridState();
}
class _RotatingGridState extends State<RotatingGrid> with TickerProviderStateMixin {
late final AnimationController _controller = AnimationController.unbounded(
vsync: this,
duration: const Duration(milliseconds: 750),
);
final _trigerIndex = ValueNotifier(0);
@override
Widget build(BuildContext context) {
final colors = [
Colors.red.shade700, Colors.green.shade700, Colors.blue.shade700, Colors.orange,
];
final labels = [
'red', 'green', 'blue', 'orange',
];
return Center(
child: AspectRatio(
aspectRatio: 1,
child: FlowPainter(
delegate: RotatingGridDelegate(_controller, _trigerIndex),
clipBehavior: Clip.none,
children: colors.mapIndexed((i, color) => Card(
color: color,
elevation: 4,
child: InkWell(
onTap: () {
_trigerIndex.value = i;
_controller.animateTo((_controller.value + 1).floorToDouble());
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: FittedBox(child: Text(labels[i])),
),
),
)).toList(),
),
),
);
}
}
class RotatingGridDelegate extends FlowPainterDelegate {
RotatingGridDelegate(this._controller, this._trigerIndex) : super(repaint: _controller);
final AnimationController _controller;
final ValueNotifier<int> _trigerIndex;
late List<Curve> curves;
double radius = 0;
late Paint _paint;
@override
void foregroundPaint(Canvas canvas, Size size) {
final base = _controller.value.floor();
if (base % 3 != 1) return;
final fraction = _controller.value - base;
if (fraction == 0) return;
final center = size.center(Offset.zero);
final m = FlowPaintingContextExtension.composeMatrix(
anchor: center,
translate: center,
rotation: 0.75 * pi * _controller.value,
scale: lerpDouble(1, 4, fraction)!,
);
_paint.color = Colors.white.withOpacity(1 - fraction);
canvas
..save()
..transform(m.storage)
..drawPaint(_paint)
..drawPaint(_paint)
..restore();
}
@override
void paint(Canvas canvas, Size size) {
}
@override
void paintChildren(FlowPaintingContext context) {
// timeDilation = 5;
final center = context.size.center(Offset.zero);
final anchor = (context.size / 2).center(Offset.zero);
for (int i = 0; i < 4; i++) {
final index = (i + _trigerIndex.value) % 4;
final base = _controller.value.floor();
final fraction = _controller.value - base;
// final a = 0.75 * pi * (base + curves[i].transform(fraction));
final a = 0.5 * pi * (base + curves[i].transform(fraction));
context.paintChildComposedOf(index,
anchor: anchor,
translate: center + Offset.fromDirection(-pi * 0.75 + a + index * pi / 2, radius),
rotation: pi * .125 * pow(sin(a * 2), 2),
);
}
}
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return constraints / 2;
}
@override
Size getSize(BoxConstraints constraints) {
final size = constraints.biggest;
radius = size.bottomRight(Offset.zero).distance / 4;
const N = 8;
curves = List.generate(4, (i) => Interval(i / N, (N - 4 + 1 + i) / N, curve: Curves.decelerate));
_paint = Paint()
..blendMode = BlendMode.overlay
..shader = const LinearGradient(
colors: [Color(0x00ffffff), Color(0xffffffff), Color(0x00ffffff)],
stops: [0.4, 0.5, 0.6],
tileMode: TileMode.repeated).createShader(Offset.zero & size);
return size;
}
@override
bool shouldRepaint(covariant FlowDelegate oldDelegate) => false;
}
// -----------------------------------------------------------------------------
class Clock extends StatefulWidget {
@override
State<Clock> createState() => _ClockState();
}
class _ClockState extends State<Clock> with TickerProviderStateMixin {
late final ctrl = AnimationController.unbounded(vsync: this);
late final ctrl2 = AnimationController(vsync: this, duration: const Duration(milliseconds: 625));
@override
void initState() {
super.initState();
_startTime();
}
bool loop = true;
_startTime() async {
print('_startTime');
while (loop) {
final now = DateTime.now();
ctrl.value = now.hour.toDouble() * 60 * 60 + now.minute * 60 + now.second;
if (now.second % 10 == 0) {
ctrl2.forward(from: 0);
}
final ms = 1000 - now.millisecond;
print('wait ${ms}ms');
await Future.delayed(Duration(milliseconds: ms));
if (!loop) break;
await ctrl.animateTo(
(ctrl.value + 1).roundToDouble(),
duration: const Duration(milliseconds: 400),
curve: (ctrl.value % 20) < 10? Curves.elasticOut : Curves.bounceOut,
);
}
print('_startTime bye bye');
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.blueGrey,
padding: const EdgeInsets.all(16),
child: FlowPainter(
delegate: ClockDelegate(ctrl, ctrl2),
children: [
CustomPaint(
painter: ClockPainter(),
child: const SizedBox.expand(),
),
_hand(const Size(4, 120), 30, Colors.teal, true),
_hand(const Size(3, 120), 40, Colors.orange, true),
_hand(const Size(1.5, 120), 50, Colors.black54, false),
],
),
);
}
@override
void dispose() {
loop = false;
ctrl
..stop()
..dispose();
ctrl2.dispose;
super.dispose();
}
Widget _hand(Size size, double innerHeight, color, bool sharpEnd) {
return Container(
width: size.width,
height: size.height,
alignment: Alignment.topCenter,
child: SizedBox(
width: size.width,
height: innerHeight,
child: Transform.translate(
offset: Offset(0, size.height / 2 - innerHeight + size.width / 2),
child: DecoratedBox(
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: sharpEnd?
Radius.elliptical(size.width / 2, innerHeight) :
Radius.circular(size.width / 4),
bottom: Radius.circular(size.width / 2),
)
),
color: color,
),
),
),
),
);
}
}
class ClockDelegate extends FlowPainterDelegate {
ClockDelegate(this.ctrl, this.ctrl2) : super(repaint: Listenable.merge([ctrl, ctrl2]));
final AnimationController ctrl;
final AnimationController ctrl2;
@override
void paintChildren(FlowPaintingContext context) {
final center = context.size.center(Offset.zero);
context.paintChild(0);
final hoursSize = context.getChildSize(1);
if (hoursSize == null || hoursSize == Size.zero) return;
context.paintChildComposedOf(1,
rotation: ctrl.value * 2 * pi / (60 * 60 * 12),
translate: center,
anchor: hoursSize.center(Offset.zero),
scale: context.size.shortestSide / hoursSize.height,
);
final minutesSize = context.getChildSize(2);
if (minutesSize == null || minutesSize == Size.zero) return;
context.paintChildComposedOf(2,
rotation: ctrl.value * 2 * pi / (60 * 60),
translate: center,
anchor: minutesSize.center(Offset.zero),
scale: context.size.shortestSide / minutesSize.height,
);
final secondsSize = context.getChildSize(3);
if (secondsSize == null || secondsSize == Size.zero) return;
context.paintChildComposedOf(3,
rotation: (ctrl.value % 60) * 2 * pi / 60,
translate: center,
anchor: secondsSize.center(Offset.zero),
scale: context.size.shortestSide / secondsSize.height,
);
}
@override
bool shouldRepaint(covariant FlowDelegate oldDelegate) => false;
@override
void foregroundPaint(Canvas canvas, Size size) {
}
@override
void paint(Canvas canvas, Size size) {
if (ctrl2.isAnimating) {
final a = sin(ctrl2.value * pi);
final h = (1.9 * ctrl.value) % 360;
final r = Rect.fromCircle(
center: size.center(Offset.zero),
radius: Curves.easeInOut.transform(ctrl2.value) * 0.5 * size.shortestSide,
);
final path1 = StarBorder(
points: 5,
innerRadiusRatio: lerpDouble(0.3, 1, ctrl2.value)!,
rotation: 720 * ctrl2.value / 5,
).getOuterPath(r);
final path2 = StarBorder(
points: 7,
innerRadiusRatio: lerpDouble(0.6, 0.8, ctrl2.value)!,
rotation: -720 * ctrl2.value / 5,
).getOuterPath(r);
final paint = Paint()
..color = HSVColor.fromAHSV(a, h, 0.5, 1).toColor()
..maskFilter = const MaskFilter.blur(BlurStyle.outer, 12);
canvas
..drawPath(path1, paint)
..drawPath(path2, paint);
}
}
}
class ClockPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
print('ClockPainter.paint');
final center = size.center(Offset.zero);
final ringWidth = size.shortestSide * 0.05;
BoxDecoration(
shape: BoxShape.circle,
border: Border.all(width: ringWidth, color: Colors.white30),
color: Colors.black26,
).createBoxPainter().paint(canvas, Offset.zero, ImageConfiguration(size: size));
final paint = Paint()
..color = Colors.black45
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
final matrix = FlowPaintingContextExtension.composeMatrix(
anchor: center,
translate: center, rotation: pi / 30,
);
final p1 = center.translate(0, -(size.shortestSide * 0.5 - ringWidth * 1.5));
final p2 = p1.translate(0, ringWidth);
final p3 = p1.translate(0, ringWidth * 2);
for (int i = 0; i < 60; i++) {
canvas.drawLine(p1, i % 5 == 0? p3 : p2, paint);
canvas.transform(matrix.storage);
}
}
@override
bool shouldRepaint(ClockPainter oldDelegate) => false;
}
// -----------------------------------------------------------------------------
class Split extends StatefulWidget {
@override
State<Split> createState() => _SplitState();
}
class _SplitState extends State<Split> with TickerProviderStateMixin {
final _controller = ScrollController();
bool useIcons = false;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return Column(
children: [
ColoredBox(
color: Colors.blueGrey,
child: CheckboxListTile(
value: useIcons,
onChanged: (v) => setState(() => useIcons = v!),
title: const Text('show icons instead of images'),
),
),
Expanded(
child: Stack(
children: [
Container(color: const Color(0xff000033)),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: _controller,
physics: const PageScrollPhysics(),
child: FlowPainter(
delegate: SplitDelegate(_controller, constraints.maxWidth),
children: [
_buildImage(10, true, const Icon(Icons.alarm, color: Colors.orange)),
_buildImage(10, false, const Icon(Icons.alarm, color: Colors.deepPurple)),
_buildImage(12, true, const Icon(Icons.account_box, color: Color(0xff006600))),
_buildImage(12, false, const Icon(Icons.account_box, color: Color(0xffcc0000))),
_buildImage(14, true, const Icon(Icons.broken_image_outlined, color: Colors.blueGrey)),
_buildImage(14, false, const Icon(Icons.broken_image_outlined, color: Colors.blue)),
],
),
),
],
),
),
],
);
}
);
}
Widget _buildImage(int id, bool top, Widget errorWidget) {
return ClipRect(
child: Transform.scale(
scale: 2,
alignment: top? Alignment.topCenter : Alignment.bottomCenter,
child: Image.network(
useIcons? 'foo://' : 'https://picsum.photos/id/$id/400/400.jpg',
fit: BoxFit.cover,
errorBuilder: (ctx, err, st) {
print(err);
return FittedBox(child: errorWidget);
},
),
),
);
}
}
class SplitDelegate extends FlowPainterDelegate {
SplitDelegate(this._controller, this._parentWidth) : super(repaint: _controller);
final ScrollController _controller;
final double _parentWidth;
@override
void foregroundPaint(Canvas canvas, Size size) {
}
@override
void paint(Canvas canvas, Size size) {
}
@override
void paintChildren(FlowPaintingContext context) {
final t0 = (_controller.offset / _parentWidth).clamp(0.0, 1.0);
final t1 = ((_controller.offset - _parentWidth) / _parentWidth).clamp(0.0, 1.0);
final t2 = ((2 * _parentWidth - _controller.offset) / _parentWidth).clamp(0.0, 1.0);
final fromRects = List.generate(6, (i) {
final r = Offset(_parentWidth * (i ~/ 2), 0) & Size(_parentWidth, context.size.height);
final s = context.getChildSize(0)!;
final dy = i.isEven? -s.height / 2 : s.height / 2;
return Alignment.center.inscribe(s, r).translate(0, dy);
});
final toRects = fromRects.mapIndexed((i, r) {
final dy = i.isEven? -context.size.height / 2 : context.size.height / 2;
return r.translate(0, dy);
}).toList();
final anchors = fromRects.map((r) => r.size.center(Offset.zero)).toList();
for (int i = 0; i <= 1; i++) {
context.paintChildComposedOf(i,
anchor: anchors[i],
translate: anchors[i] + Offset.lerp(fromRects[i].topLeft, toRects[i].topLeft, t0)!,
scale: lerpDouble(1, 4, t0)!,
rotation: pi * t0,
opacity: 1 - t0,
);
}
for (int i = 2; i <= 3; i++) {
context.paintChildTranslated(i, Offset.lerp(fromRects[i].topLeft, toRects[i].topLeft, t1)!,
opacity: 1 - t1,
);
}
for (int i = 4; i <= 5; i++) {
context.paintChildComposedOf(i,
anchor: anchors[i],
translate: anchors[i] + Offset.lerp(fromRects[i].topLeft, toRects[i].topLeft, t2)!,
scale: lerpDouble(1, 2, t2)!,
opacity: 1 - t2,
);
}
}
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return constraints.tighten(width: _parentWidth, height: constraints.maxHeight * 0.33);
}
@override
Size getSize(BoxConstraints constraints) {
return Size(3 * _parentWidth, constraints.maxHeight);
}
@override
bool shouldRepaint(covariant FlowDelegate oldDelegate) => true;
}
import 'dart:math';
import 'package:flutter/rendering.dart';
extension FlowPaintingContextExtension on FlowPaintingContext {
paintChildTranslated(int i, Offset translate, { double opacity = 1.0 }) => paintChild(i,
transform: composeMatrix(translate: translate),
opacity: opacity,
);
/// Paints the [i]th child using [fit] and [alignment] to position the child
/// within a given [rect] (optionally deflated by [padding]).
///
/// By default the following values are used:
///
/// - [rect] = Offset.zero & size - the entire area of [Flow] widget
/// - [padding] = null - when specified it is used to deflate [rect]
/// - [fit] = BoxFit.none
/// - [alignment] = Alignment.topLeft
/// - [opacity] = 1.0
///
paintChildInRect(int i, {
Rect? rect,
EdgeInsets? padding,
BoxFit fit = BoxFit.none,
Alignment alignment = Alignment.topLeft,
double opacity = 1.0,
}) {
rect ??= Offset.zero & size;
if (padding != null) {
rect = padding.deflateRect(rect);
}
paintChild(i,
transform: sizeToRect(getChildSize(i)!, rect, fit: fit, alignment: alignment),
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 [translate]
///
/// 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: composeMatrix(
scale: scale,
rotation: rotation,
translate: translate,
anchor: anchor,
),
opacity: opacity,
);
static Matrix4 sizeToRect(Size src, Rect dst, {BoxFit fit = BoxFit.contain, Alignment alignment = Alignment.center}) {
FittedSizes fs = applyBoxFit(fit, src, dst.size);
double scaleX = fs.destination.width / fs.source.width;
double scaleY = fs.destination.height / fs.source.height;
Size fittedSrc = Size(src.width * scaleX, src.height * scaleY);
Rect out = alignment.inscribe(fittedSrc, dst);
return Matrix4.identity()
..translate(out.left, out.top)
..scale(scaleX, scaleY);
}
static Matrix4 composeMatrix({
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);
}
}
extension AlignSizeToRectExtension on Rect {
/// Returns a [Rect] (output [Rect]) with given [size] aligned to this [Rect]
/// (input [Rect]) in such a way that [inputAnchor] applied to input [Rect]
/// lines up with [outputAnchor] applied to output [Rect].
///
/// For example if [inputAnchor] is [Alignment.bottomCenter] and [outputAnchor] is
/// [Alignment.topCenter] the output [Rect] is as follows (two points that
/// line up are shown as █):
///
/// ┌─────────────────────┐
/// │ input Rect │
/// └───┲━━━━━━█━━━━━━┱───┘
/// ┃ output Rect ┃
/// ┃ ┃
/// ┗━━━━━━━━━━━━━┛
///
/// another example: [inputAnchor] is [Alignment.bottomRight] and
/// [outputAnchor] is [Alignment.topRight]:
///
/// ┌─────────────────────┐
/// │ input Rect │
/// └───────┲━━━━━━━━━━━━━█
/// ┃ output Rect ┃
/// ┃ ┃
/// ┗━━━━━━━━━━━━━┛
///
/// yet another example: [inputAnchor] is [Alignment.bottomRight] and
/// [outputAnchor] is [Alignment.bottomLeft]:
///
/// ┏━━━━━━━━━━━━━┓
/// ┌─────────────────────┨ output Rect ┃
/// │ input Rect ┃ ┃
/// └─────────────────────█━━━━━━━━━━━━━┛
///
Rect alignSize(Size size, Alignment inputAnchor, Alignment outputAnchor, [Offset extraOffset = Offset.zero]) {
final inputOffset = inputAnchor.withinRect(this);
final outputOffset = outputAnchor.alongSize(size);
Offset offset = inputOffset - outputOffset;
if (extraOffset != Offset.zero) offset += extraOffset;
return offset & size;
}
}
import 'dart:math';
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'flow_painting_context_extension.dart';
import 'flow_v2.dart';
export 'flow_painting_context_extension.dart' show FlowPaintingContextExtension;
/// Signature for [FlowStackEntry.paintCallback]
///
/// [pctx] is used for calling one of 'paintChild*' method on [i]th child:
/// - [FlowPaintingContext.paintChild]
/// - [FlowPaintingContextExtension.paintChildTranslated]
/// - [FlowPaintingContextExtension.paintChildComposedOf]
///
/// Two additional parameters are passed for convenience (both obtained from [pctx]):
/// - [size] - [i]th child size
/// - [rect] - parent's bounding rect
typedef PaintCallback = void Function(int i, FlowPaintingContext pctx, Size size, Rect rect);
/// Signature for [FlowStack.sizeCallback]
///
/// [pctx] is commonly used for calling [FlowPaintingContext.getChildSize] in order to compute the final size
///
/// [delegate] can be used for calling [FlowV2Delegate.index] or getting [FlowV2Delegate.constraints] passed
/// by a parent
typedef SizeCallback = Size Function(FlowPaintingContext pctx, FlowV2Delegate delegate);
class FlowStack extends StatelessWidget {
const FlowStack({
super.key,
this.repaint,
this.relayout,
this.children = const [],
this.clipBehavior = Clip.hardEdge,
this.wrapped = true,
this.sizeCallback,
});
FlowStack.fromDelegates({
Key? key,
Listenable? repaint,
Listenable? relayout,
required Iterable<({
FlowStackLayoutDelegate delegate,
Iterable<dynamic> ids,
Iterable<Widget> children,
})> delegates,
Clip clipBehavior = Clip.hardEdge,
bool wrapped = true,
SizeCallback? sizeCallback,
}) : this(
key: key,
repaint: repaint,
relayout: relayout,
children: [
...delegates.expand((record) =>
FlowStackEntry.delegatedList(
delegate: record.delegate,
children: record.children,
ids: record.ids,
),
),
],
clipBehavior: clipBehavior,
wrapped: wrapped,
sizeCallback: sizeCallback,
);
FlowStack.fromDelegate({
Key? key,
Listenable? repaint,
Listenable? relayout,
required FlowStackLayoutDelegate delegate,
required Iterable<dynamic> ids,
required Iterable<Widget> children,
Clip clipBehavior = Clip.hardEdge,
bool wrapped = true,
SizeCallback? sizeCallback,
}) : this(
key: key,
repaint: repaint,
relayout: relayout,
children: FlowStackEntry.delegatedList(
delegate: delegate,
children: children,
ids: ids,
),
clipBehavior: clipBehavior,
wrapped: wrapped,
sizeCallback: sizeCallback,
);
final Listenable? repaint;
final Listenable? relayout;
final Clip clipBehavior;
final List<FlowStackEntry> children;
final bool wrapped;
final SizeCallback? sizeCallback;
@override
Widget build(BuildContext context) {
final children_ = children.map((e) => e.child).toList();
if (sizeCallback == null && relayout == null) {
final delegate = _FlowStackDelegate(
entries: children,
repaint: repaint,
);
return wrapped?
Flow(
delegate: delegate,
clipBehavior: clipBehavior,
children: children_,
) :
Flow.unwrapped(
delegate: delegate,
clipBehavior: clipBehavior,
children: children_,
);
} else {
final delegate = _FlowV2StackDelegate(
entries: children,
repaint: repaint,
relayout: relayout,
sizeCallback: sizeCallback ?? _biggestSizeCallback,
);
return wrapped?
FlowV2(
delegate: delegate,
clipBehavior: clipBehavior,
children: children_,
) :
FlowV2.unwrapped(
delegate: delegate,
clipBehavior: clipBehavior,
children: children_,
);
}
}
static Size _biggestSizeCallback(FlowPaintingContext pctx, FlowV2Delegate delegate) => delegate.constraints.biggest;
}
/// helper paint callbacks that can be used with generic [FlowStackEntry.fromCallbacks] constructor.
PaintCallback alignedPaintCallback(Alignment alignment, {
Offset? offset,
Offset? fractionalOffset,
}) {
return (i, pctx, size, rect) {
Offset translate = alignment.inscribe(size, rect).topLeft;
if (offset != null) translate += offset;
if (fractionalOffset != null) translate += fractionalOffset.scale(size.width, size.height);
pctx.paintChildTranslated(i, translate);
};
}
PaintCallback translatedPaintCallback(Offset o, {Alignment anchor = Alignment.topLeft}) {
return (i, pctx, size, rect) => pctx.paintChildTranslated(i, o - anchor.alongSize(size));
}
class FlowStackEntry {
/// The generic constructor, [paintCallback] is responsible for calling one of:
/// - [FlowPaintingContext.paintChild]
/// - [FlowPaintingContextExtension.paintChildTranslated]
/// - [FlowPaintingContextExtension.paintChildComposedOf]
/// Optional [constraintsCallback] provides [BoxConstraints] used for [child]
/// sizing.
///
/// For simple cases where all layout data is static you can use:
/// - [FlowStackEntry.at]
/// - [FlowStackEntry.tight]
/// - [FlowStackEntry.aligned]
/// - [FlowStackEntry.filled]
/// - [FlowStackEntry.follower]
FlowStackEntry.fromCallbacks({
required this.paintCallback,
this.constraintsCallback,
required this.child,
});
/// Sets [child] at given [offset], by default [child]'s top-left corner is
/// aligned to [offset], this can changed with optional [anchor] parameter.
///
/// Optional [constraints] can be set to specify [BoxConstraints] used for [child]
/// sizing.
FlowStackEntry.at({
required Offset offset,
BoxConstraints? constraints,
Alignment anchor = Alignment.topLeft,
required this.child,
}) : paintCallback = translatedPaintCallback(offset, anchor: anchor),
constraintsCallback = (constraints != null? (c) => constraints : null);
/// Sets [child] at given [rect].
FlowStackEntry.tight({
required Rect rect,
required Widget child,
}) : this.at(
offset: rect.topLeft,
constraints: BoxConstraints.tight(rect.size),
child: child,
);
/// Aligns [child] within parent's size by given [alignment], the final position
/// can be adjusted with optional [offset] / [fractionalOffset] that moves [child]
/// by additional (offset.dx, offset.dy) and
/// (fractionalOffset.dx * childSize.width, fractionalOffset.dy * childSize.height)
/// pixels respectively. Note that [fractionalOffset] is expressed as [Offset]
/// scaled to the [child]'s size. For example, 'fractionalOffset: Offset(1, 0)' will move
/// [child] by (childSize.width, 0) pixels.
///
/// Optional [constraints] can be set to specify [BoxConstraints] used for [child]
/// sizing.
FlowStackEntry.aligned({
required Alignment alignment,
BoxConstraints? constraints,
Offset? offset,
Offset? fractionalOffset,
required this.child,
}) : paintCallback = alignedPaintCallback(alignment, offset: offset, fractionalOffset: fractionalOffset),
constraintsCallback = (constraints != null? (c) => constraints : null);
/// Fills [child] by deflating parent's size with given [padding].
FlowStackEntry.filled({
required EdgeInsets padding,
required this.child,
}) : paintCallback = ((i, pctx, size, rect) => pctx.paintChildTranslated(i, padding.topLeft)),
constraintsCallback = ((c) => BoxConstraints.tight(padding.deflateSize(c.biggest)).enforce(c));
/// Animates [child] based on [animation] and [transform] callback.
/// Note that [animation] has to be passed to [FlowStack.repaint] - otherwise no
/// animation will happen.
///
/// If you want to change opacity during the animation use generic
/// [FlowStackEntry.fromCallbacks] constructor.
FlowStackEntry.animated({
required Animation<double> animation,
required Matrix4 Function(Animation<double>, Size, Rect) transform,
required this.child,
}) : paintCallback = ((i, pctx, size, rect) => pctx.paintChild(i,
transform: transform(animation, size, rect)
)),
constraintsCallback = null;
/// Follows [CompositedTransformTarget] with given [link].
/// Optional parameters: [offset], [targetAnchor] and [followerAnchor] are
/// directly passed to [CompositedTransformFollower].
/// Note that followed [CompositedTransformTarget] must be specified in
/// [FlowStackEntry] that is painted before this one.
///
/// Optional [constraints] can be set to specify [BoxConstraints] used for [child]
/// sizing.
FlowStackEntry.follower({
required LayerLink link,
Offset offset = Offset.zero,
Alignment targetAnchor = Alignment.topLeft,
Alignment followerAnchor = Alignment.topLeft,
BoxConstraints? constraints,
required Widget child,
}) : child = CompositedTransformFollower(
link: link,
offset: offset,
targetAnchor: targetAnchor,
followerAnchor: followerAnchor,
child: child,
),
paintCallback = translatedPaintCallback(Offset.zero),
constraintsCallback = (constraints != null? (c) => constraints : null);
/// Delegates sizing / painting the [child] to given [delegate].
/// [delegate] should use [id] when calling [FlowStackLayoutDelegate.getChildSize],
/// [FlowStackLayoutDelegate.paintChildTranslated] and [FlowStackLayoutDelegate.paintChildComposedOf].
FlowStackEntry.delegated({
required FlowStackLayoutDelegate delegate,
required dynamic id,
required this.child,
}) : paintCallback = delegate._paintCallback,
constraintsCallback = ((BoxConstraints c) => delegate.getConstraintsForChild(id, c)),
delegateContext = (delegate: delegate, id: id);
/// A helper method to group multiple [FlowStackEntry.delegated] entries into a [List].
///
/// Can be used like this:
///
/// ```dart
/// FlowStack(
/// children: [
/// ...FlowStackEntry.delegatedList(
/// delegate: delegateOne,
/// children: [childA0, childA1, childA2],
/// ids: [0, 1, 2],
/// ),
/// ...FlowStackEntry.delegatedList(
/// delegate: delegateTwo,
/// children: [childB0, childB1],
/// ids: ['top', 'bottom'],
/// ),
/// ],
/// )
/// ```
static List<FlowStackEntry> delegatedList({
required FlowStackLayoutDelegate delegate,
required Iterable<Widget> children,
required Iterable<dynamic> ids,
}) {
assert(children.length == ids.length);
return IterableZip([ids, children]).map((zip) => FlowStackEntry.delegated(
delegate: delegate,
id: zip[0],
child: zip[1],
)).toList();
}
/// Cosmetic version of [delegatedList].
///
/// Iterables: "children" and "ids" are replaced with single [records] iterable.
static List<FlowStackEntry> delegatedListFromRecords({
required FlowStackLayoutDelegate delegate,
required Iterable<(dynamic id, Widget child)> records,
}) {
return records.map((record) {
final (id, child) = record;
return FlowStackEntry.delegated(
delegate: delegate,
id: id,
child: child,
);
}).toList();
}
final PaintCallback paintCallback;
final BoxConstraints Function(BoxConstraints)? constraintsCallback;
({FlowStackLayoutDelegate delegate, dynamic id})? delegateContext;
final Widget child;
}
typedef _EntryRecord = ({int index, FlowStackEntry entry});
abstract class FlowStackLayoutDelegate with Diagnosticable {
final _paintData = <dynamic, ({Matrix4 transform, double opacity})>{};
final _idToIndex = <dynamic, int>{};
final _indexToId = <int, dynamic>{};
late FlowPaintingContext _context;
/// Layout children of this delegate by calling [paintChildTranslated] or
/// [paintChildComposedOf].
/// You can also call [getChildSize] if the position of one child depends on the
/// size of any child.
layout(Size size);
/// The size of the child with given [id].
Size getChildSize(dynamic id) {
assert(_idToIndex.containsKey(id), _notFoundMessage(id));
return _context.getChildSize(_idToIndex[id]!)!;
}
/// Paint a child with a translation.
paintChildTranslated(dynamic id, Offset translate, [double opacity = 1.0]) {
assert(_idToIndex.containsKey(id), _notFoundMessage(id));
final transform = FlowPaintingContextExtension.composeMatrix(translate: translate);
_paintData[id] = (transform: transform, opacity: opacity);
}
/// Paint a child within a [Rect].
/// See [FlowPaintingContextExtension.paintChildInRect] for more info.
paintChildInRect(dynamic id, Rect rect, {
EdgeInsets? padding,
BoxFit fit = BoxFit.none,
Alignment alignment = Alignment.topLeft,
double opacity = 1.0,
}) {
assert(_idToIndex.containsKey(id), _notFoundMessage(id));
if (padding != null) {
rect = padding.deflateRect(rect);
}
final transform = FlowPaintingContextExtension.sizeToRect(getChildSize(id), rect, fit: fit, alignment: alignment);
_paintData[id] = (transform: transform, opacity: opacity);
}
/// Paint a child with a full transformation.
/// See [FlowPaintingContextExtension.paintChildComposedOf] for more info.
paintChildComposedOf(dynamic id, {
double scale = 1,
double rotation = 0,
Offset translate = Offset.zero,
Offset anchor = Offset.zero,
double opacity = 1.0,
}) {
assert(_idToIndex.containsKey(id), _notFoundMessage(id));
final transform = FlowPaintingContextExtension.composeMatrix(
scale: scale,
rotation: rotation,
translate: translate,
anchor: anchor,
);
_paintData[id] = (transform: transform, opacity: opacity);
}
_notFoundMessage(dynamic id) => 'id |$id| not found\navailable ids: ${_idToIndex.keys}';
_setup(List<_EntryRecord> records) {
_idToIndex.clear();
_indexToId.clear();
for (final record in records) {
final index = record.index;
final id = record.entry.delegateContext!.id;
_idToIndex[id] = index;
_indexToId[index] = id;
}
// debugPrint(toDiagnosticsNode().toStringDeep(prefixLineOne: '| '));
}
_layout(FlowPaintingContext context) {
_context = context;
_paintData.clear();
layout(context.size);
}
_paintCallback(int i, FlowPaintingContext pctx, Size size, Rect rect) {
final pd = _paintData[_indexToId[i]];
if (pd == null) return; // no paintChild* method called on this child
pctx.paintChild(i, transform: pd.transform, opacity: pd.opacity);
}
/// Override to control the layout constraints given to each child.
/// See [FlowDelegate.getConstraintsForChild] for more info.
BoxConstraints getConstraintsForChild(dynamic id, BoxConstraints constraints) => constraints.loosen();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(MessageProperty('_idToIndex', _idToIndex.toString()))
..add(MessageProperty('_indexToId', _indexToId.toString()));
}
}
Map<FlowStackLayoutDelegate, List<_EntryRecord>> _groupEntriesByDelegate(List<FlowStackEntry> entries) {
final groupedEntries = <FlowStackLayoutDelegate, List<_EntryRecord>>{};
entries.forEachIndexed((index, entry) {
if (entry.delegateContext != null) {
final list = groupedEntries[entry.delegateContext!.delegate] ??= [];
list.add((index: index, entry: entry));
}
});
for (final delegate in groupedEntries.keys) {
delegate._setup(groupedEntries[delegate]!);
}
return groupedEntries;
}
class _FlowStackDelegate extends FlowDelegate {
_FlowStackDelegate({
required this.entries,
required Listenable? repaint,
}) : _groupedEntries = _groupEntriesByDelegate(entries), super(repaint: repaint);
final List<FlowStackEntry> entries;
final Map<FlowStackLayoutDelegate, List<_EntryRecord>> _groupedEntries;
@override
void paintChildren(FlowPaintingContext context) {
final r = Offset.zero & context.size;
int i = 0;
for (final delegate in _groupedEntries.keys) {
delegate._layout(context);
}
for (final entry in entries) {
entry.paintCallback(i, context, context.getChildSize(i)!, r);
i++;
}
}
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return entries[i].constraintsCallback?.call(constraints) ?? constraints.loosen();
}
@override
bool shouldRepaint(covariant FlowDelegate oldDelegate) => true;
@override
bool shouldRelayout(covariant FlowDelegate oldDelegate) => true;
}
class _FlowV2StackDelegate extends FlowV2Delegate {
_FlowV2StackDelegate({
required this.entries,
required Listenable? repaint,
required Listenable? relayout,
required this.sizeCallback,
}) : _groupedEntries = _groupEntriesByDelegate(entries), super(repaint: repaint, relayout: relayout);
final List<FlowStackEntry> entries;
final SizeCallback sizeCallback;
final Map<FlowStackLayoutDelegate, List<_EntryRecord>> _groupedEntries;
@override
Size layout(bool isSizing, FlowPaintingContext context) {
if (!isSizing) {
final r = Offset.zero & context.size;
int i = 0;
for (final delegate in _groupedEntries.keys) {
delegate._layout(context);
}
for (final entry in entries) {
entry.paintCallback(i, context, context.getChildSize(i)!, r);
i++;
}
}
return isSizing? sizeCallback.call(context, this) : Size.zero;
}
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return entries[i].constraintsCallback?.call(constraints) ?? constraints.loosen();
}
}
// =============================================================================
//
// example
//
main() {
runApp(MaterialApp(
scrollBehavior: const MaterialScrollBehavior().copyWith(
dragDevices: {PointerDeviceKind.mouse, PointerDeviceKind.touch},
),
theme: ThemeData.light(useMaterial3: false),
home: Scaffold(
body: Builder(
builder: (context) {
return ListView(
children: [
ListTile(
title: const Text('overview'),
subtitle: const Text('_FlowStackOverview(),'),
onTap: () {
final route = MaterialPageRoute(builder: (ctx) => Scaffold(
appBar: AppBar(),
body: _FlowStackOverview(),
));
Navigator.of(context).push(route);
},
),
ListTile(
title: const Text('delegates'),
subtitle: const Text('_FlowStackDelegates(),'),
onTap: () {
final route = MaterialPageRoute(builder: (ctx) => Scaffold(
appBar: AppBar(),
body: _FlowStackDelegates(),
));
Navigator.of(context).push(route);
},
),
ListTile(
title: const Text('page turn'),
subtitle: const Text('_FlowStackPageTurn(),'),
onTap: () {
final route = MaterialPageRoute(builder: (ctx) => Scaffold(
appBar: AppBar(),
body: _FlowStackPageTurn(),
));
Navigator.of(context).push(route);
},
),
],
);
}
),
),
));
}
class _FlowStackOverview extends StatefulWidget {
@override
State<_FlowStackOverview> createState() => _FlowStackOverviewState();
}
class _FlowStackOverviewState extends State<_FlowStackOverview> with TickerProviderStateMixin {
late final ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 650));
late final ca = CurvedAnimation(parent: ctrl, curve: Curves.easeOutBack, reverseCurve: Curves.easeInBack);
final countNotifier = ValueNotifier(-1);
@override
Widget build(BuildContext context) {
return FlowStack(
repaint: ctrl,
children: [
// label 0
FlowStackEntry.filled(
padding: const EdgeInsets.symmetric(horizontal: 25, vertical: 50),
child: Badge(
label: const Text('0'),
child: SizedBox.expand(
child: ColoredBox(
color: Colors.grey.shade400,
child: const Text('filled\nhorizontal: 25, vertical: 50'),
),
),
),
),
..._grid(false),
// this one should really be simplified with "FlowStackEntry.at"
// see the next entry (label 3) centered at 100, 200
// label 1
FlowStackEntry.fromCallbacks(
paintCallback: translatedPaintCallback(const Offset(100, 100)),
child: _FooCard(
color: Colors.orange.shade400,
label: '1',
alignment: Alignment.topLeft,
child: const Text('topLeft\n@100,100'),
),
),
// label 2
FlowStackEntry.animated(
animation: ctrl,
transform: _MoveAndTurn(const Offset(200, 200), const Offset(100, 100), countNotifier),
child: _FooCard(
color: Colors.blue.shade400,
label: '2',
alignment: Alignment.center,
child: const Text('animated\n\nbottomLeft\n@200,200\nor\nbottomRight\n@100,100'),
),
),
// label 3
FlowStackEntry.at(
offset: const Offset(100, 200),
anchor: Alignment.center,
child: _FooCard(
color: Colors.green.shade400,
label: '3',
alignment: Alignment.center,
child: const Text('at\ncenter\n@100,200'),
),
),
// label 4
FlowStackEntry.at(
offset: const Offset(300, 200),
anchor: Alignment.centerRight,
child: _FooCard(
color: Colors.yellow.shade400,
label: '4',
alignment: Alignment.centerRight,
child: const Text('at\ncenterRight\n@300,200'),
),
),
FlowStackEntry.fromCallbacks(
paintCallback: alignedPaintCallback(Alignment.center),
constraintsCallback: (c) => BoxConstraints.tightFor(width: c.maxWidth * 0.65),
child: Badge(
label: const Text('A'),
child: ElevatedButton(
onPressed: () {
ctrl.value < 0.5? ctrl.forward() : ctrl.reverse();
countNotifier.value++;
},
child: const Text('center aligned button, click to animate', textScaleFactor: 1.75),
),
),
),
// note that SizedBox can be removed and constraints: ... can be used instead
// label 5
FlowStackEntry.at(
offset: const Offset(100, 400),
anchor: Alignment.topCenter,
child: SizedBox(
width: 80,
child: _FooCard(
color: Colors.red.shade400,
label: '5',
alignment: Alignment.topCenter,
child: const SizedBox(width: double.infinity, child: Text('at\ntopCenter\n@100,400')),
),
),
),
// instead of additional SizedBox we use constraints: ...
// label 6
FlowStackEntry.at(
offset: const Offset(200, 400),
anchor: Alignment.centerLeft,
constraints: const BoxConstraints.tightFor(width: 80),
child: _FooCard(
color: Colors.deepPurple.shade400,
label: '6',
alignment: Alignment.centerLeft,
child: const SizedBox(width: double.infinity, child: Text('at\ncenterLeft\n@200,400')),
),
),
// label 7
FlowStackEntry.tight(
rect: const Rect.fromLTWH(200, 450, 100, 100),
child: _FooCard(
color: Colors.blueGrey.shade400,
label: '7',
alignment: Alignment.center,
child: const SizedBox.expand(child: Text('tight\n@200,450,100,100')),
),
),
// label 8
FlowStackEntry.aligned(
alignment: Alignment.bottomCenter,
child: _FooCard(
color: Colors.brown.shade300,
label: '8',
alignment: Alignment.bottomCenter,
child: const Text('aligned\nbottomCenter'),
),
),
// label 9
FlowStackEntry.animated(
animation: ca,
transform: _moveWifiIconTransform,
child: AnimatedBuilder(
animation: ctrl,
builder: (context, child) {
final color = Color.lerp(Colors.indigo, Colors.orange, ctrl.value)!;
return Badge(
label: const Text('9'),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.grey.shade300.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.black45),
),
child: Icon(Icons.wifi, size: 100, color: color),
),
);
}
),
),
..._grid(true),
],
);
}
@override
void dispose() {
super.dispose();
ctrl.dispose();
}
Iterable<FlowStackEntry> _grid(bool showLabels) sync* {
const offsets = [
Offset(100, 0), Offset(200, 0), Offset(300, 0),
Offset(0, 100), Offset(0, 200), Offset(0, 400), Offset(0, 450), Offset(0, 550),
];
for (final o in offsets) {
if (showLabels) {
final borderRadius = o.dx == 0?
const BorderRadius.horizontal(right: Radius.circular(12)) :
const BorderRadius.vertical(bottom: Radius.circular(12));
yield FlowStackEntry.fromCallbacks(
paintCallback: translatedPaintCallback(o, anchor: o.dx == 0? Alignment.centerLeft : Alignment.topCenter),
child: Container(
padding: o.dx == 0?
const EdgeInsets.only(right: 6, top: 3, bottom: 3) :
const EdgeInsets.only(left: 6, right: 6, bottom: 3),
decoration: BoxDecoration(
borderRadius: borderRadius,
border: Border.all(color: Colors.black38),
color: Colors.yellow.shade200,
boxShadow: kElevationToShadow[4],
),
child: Text(o.distance.toInt().toString()),
),
);
} else {
yield FlowStackEntry.at(
offset: o,
child: o.dx == 0?
const Divider(height: 0, color: Colors.black38) :
const VerticalDivider(width: 0, color: Colors.black38),
);
}
}
}
Matrix4 _moveWifiIconTransform(Animation<double> animation, Size size, Rect rect) {
final a = Alignment.lerp(const Alignment(-1, 0.25), Alignment.bottomRight, animation.value)!;
return FlowPaintingContextExtension.composeMatrix(
anchor: size.center(Offset.zero),
translate: a.inscribe(size, rect).center,
rotation: lerpDouble(-pi / 6, pi / 2, ctrl.value)!,
);
}
}
class _MoveAndTurn {
_MoveAndTurn(this.begin, this.end, this.countNotifier) : angle = (begin - end).direction;
final Offset begin;
final Offset end;
final ValueNotifier<int> countNotifier;
final double angle;
Matrix4 call(Animation<double> animation, Size size, Rect rect) {
final t = Curves.easeInOut.transform(animation.value);
final o = Offset.lerp(begin, end, t)!;
final a = Alignment.lerp(Alignment.bottomLeft, Alignment.bottomRight, t)!;
if ((countNotifier.value ~/ 3).isEven) {
return FlowPaintingContextExtension.composeMatrix(
translate: o,
anchor: a.alongSize(size),
rotation: angle * sin(pi * t),
);
} else {
// timeDilation = 10;
final m = MatrixUtils.createCylindricalProjectionTransform(
perspective: 0.015,
orientation: Axis.horizontal,
radius: 15,
angle: 0.35 * sin(2 * pi * t),
);
final anchor = Alignment.lerp(Alignment.centerLeft, Alignment.centerRight, t)!.alongSize(size);
return Matrix4.translationValues(anchor.dx, anchor.dy, 0) *
FlowPaintingContextExtension.composeMatrix(
translate: o,
anchor: a.alongSize(size),
scale: 1 + 0.5 * sin(pi * t),
) * m * Matrix4.translationValues(-anchor.dx, -anchor.dy, 0);
}
}
}
class _FooCard extends StatefulWidget {
const _FooCard({
required this.color,
required this.label,
required this.alignment,
required this.child,
});
final Color color;
final String label;
final Alignment alignment;
final Widget child;
@override
State<_FooCard> createState() => _FooCardState();
}
class _FooCardState extends State<_FooCard> with SingleTickerProviderStateMixin {
late final ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (d) => ctrl.animateTo(1, curve: Curves.easeOutBack),
onTapUp: (d) => ctrl.animateTo(0, curve: Curves.easeOutBack),
onTapCancel: () => ctrl.animateTo(0, curve: Curves.easeOutBack),
child: ScaleTransition(
scale: Animation.fromValueListenable(ctrl,
transformer: (v) => lerpDouble(1, 2.5, v)!,
),
alignment: widget.alignment,
child: Badge(
label: Text(widget.label),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(width: 1, color: Colors.black38),
color: widget.color,
boxShadow: kElevationToShadow[3],
),
child: Padding(
padding: const EdgeInsets.all(3),
child: widget.child,
),
),
),
),
);
}
}
// -----------------------------------------------------------------------------
class _FlowStackDelegates extends StatefulWidget {
@override
State<_FlowStackDelegates> createState() => _FlowStackDelegatesState();
}
class _FlowStackDelegatesState extends State<_FlowStackDelegates> with TickerProviderStateMixin {
late final ctrl = AnimationController.unbounded(vsync: this, duration: const Duration(milliseconds: 750));
final start = ValueNotifier(0);
final pageController = PageController(initialPage: 1000000);
double turns = 0;
@override
Widget build(BuildContext context) {
final roundMenuDelegate = _RoundMenuDelegate(ctrl, start);
final textDelegate = _TextDelegate(ctrl);
final colors = [
Colors.black, const Color(0xff00aa00), Colors.orange, const Color(0xffaa0000), const Color(0xff0000aa)
];
final roundMenuData = [
(id: 0, icon: Icons.apple, color: colors[0]),
(id: 1, icon: Icons.android, color: colors[1]),
(id: 2, icon: Icons.blur_on, color: colors[2]),
(id: 3, icon: Icons.blur_circular, color: colors[3]),
(id: 4, icon: Icons.blur_linear, color: colors[4]),
];
return FlowStack(
repaint: ctrl,
children: [
FlowStackEntry.filled(
padding: EdgeInsets.zero,
child: AnimatedBuilder(
animation: ctrl,
builder: (context, child) {
final color0 = HSVColor.fromAHSV(1, 30 * ctrl.value % 360, 1, 0.7).toColor();
final color1 = HSVColor.fromAHSV(1, 30 * ctrl.value % 360, 0.75, 0.2).toColor();
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [color0, color1],
transform: GradientRotation(-pi * 0.25 * ctrl.value),
)
),
);
}
),
),
...FlowStackEntry.delegatedList(
delegate: textDelegate,
children: const [
DecoratedBox(
decoration: BoxDecoration(border: Border.symmetric(vertical: BorderSide(color: Colors.black45))),
child: Text('press any icon to rotate them',
textAlign: TextAlign.center,
textScaleFactor: 2,
style: TextStyle(color: Colors.white38),
),
),
DecoratedBox(
decoration: BoxDecoration(border: Border.symmetric(vertical: BorderSide(color: Colors.black45))),
child: Text('it will also rotate the background gradient',
textAlign: TextAlign.center,
textScaleFactor: 1,
style: TextStyle(color: Colors.white38),
),
),
Material(
shape: StarBorder(points: 4, innerRadiusRatio: 0.33),
color: Colors.orange,
),
],
ids: [_IDS.top, _IDS.bottom, _IDS.star],
),
FlowStackEntry.delegated(
delegate: textDelegate,
id: _IDS.pageView,
child: Container(
decoration: const ShapeDecoration(
shape: CircleBorder(),
color: Colors.black26,
),
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.all(16),
child: PageView.builder(
controller: pageController,
itemBuilder: (ctx, index) {
final page = index % 5;
final pageDelegate = _PageDelegate(index, pageController);
final filter = ImageFilter.blur(sigmaX: 1.5, sigmaY: 1.0);
final icon = roundMenuData[page].icon;
final pageChildren = [
ImageFiltered(imageFilter: filter, child: Icon(icon, color: Colors.white, size: 32)),
ImageFiltered(imageFilter: filter, child: Icon(icon, color: Colors.white54, size: 32)),
Icon(icon, color: colors[page], size: 32),
];
return FittedBox(
// the "static" version:
// child: Stack(
// children: pageChildren,
// ),
child: SizedBox.fromSize(
size: const Size.square(32),
child: FlowStack.fromDelegate(
repaint: pageController,
delegate: pageDelegate,
ids: const [0, 1, 2],
children: pageChildren,
),
),
);
},
),
),
),
FlowStackEntry.aligned(
alignment: Alignment.bottomCenter,
offset: const Offset(0, 2),
constraints: const BoxConstraints.tightFor(width: 200),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
color: Colors.black38,
border: Border.all(width: 2, color: Colors.white24),
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Text('example of FlowStackEntry delegated entries', style: TextStyle(color: Colors.white70)),
),
),
),
...roundMenuData.map((e) => FlowStackEntry.delegated(
delegate: roundMenuDelegate,
id: e.id,
child: DecoratedBox(
decoration: const ShapeDecoration(
gradient: RadialGradient(center: Alignment(-0.5, -0.5), colors: [Colors.white54, Colors.transparent]),
shape: CircleBorder(
side: BorderSide(width: 4, color: Colors.white12),
),
),
child: IconButton(
color: e.color,
onPressed: () {
start.value = e.id;
final page = pageController.page!.round();
int delta = (e.id - page) % 5;
if (delta >= 3) delta -= 5;
pageController.animateToPage(page + delta,
duration: const Duration(milliseconds: 1000),
curve: Curves.ease,
);
ctrl.animateTo(++turns);
},
icon: Icon(e.icon),
iconSize: 48,
),
)),
),
],
);
}
@override
void dispose() {
super.dispose();
ctrl.dispose();
pageController.dispose();
}
}
class _RoundMenuDelegate extends FlowStackLayoutDelegate {
_RoundMenuDelegate(this.ctrl, this.start);
final AnimationController ctrl;
final ValueNotifier<int> start;
static const N = 5;
static const X = 8;
final curves = List.generate(N, (i) => Interval(i / X, (X - N + 1 + i) / X, curve: Curves.easeOut));
@override
layout(Size size) {
final maxWidth = Iterable.generate(N).fold(0.0, (acc, id) => max(acc, getChildSize(id).width));
final rect = Offset.zero & size;
final distance = (rect.shortestSide - maxWidth) / 2;
for (int id = 0; id < N; id++) {
final base = ctrl.value.floor();
final fraction = ctrl.value - base;
final a = 2 * pi * (base + curves[(id - start.value) % N].transform(fraction)) / N;
final translate = rect.center +
Offset.fromDirection(-pi / 2 + a + id * 2 * pi / N, distance) - getChildSize(id).center(Offset.zero);
paintChildTranslated(id, translate);
}
}
}
enum _IDS {
top, bottom, star, pageView,
}
class _TextDelegate extends FlowStackLayoutDelegate {
_TextDelegate(this.ctrl);
final AnimationController ctrl;
@override
layout(Size size) {
final r = Offset.zero & size;
final topSize = getChildSize(_IDS.top);
final topRect = Alignment.center.inscribe(topSize, r);
paintChildTranslated(_IDS.top, topRect.topLeft);
final bottomSize = getChildSize(_IDS.bottom);
final bottomRect = topRect.alignSize(bottomSize, Alignment.bottomCenter, Alignment.topCenter);
paintChildTranslated(_IDS.bottom, bottomRect.topLeft);
final pageViewSize = getChildSize(_IDS.pageView);
final pageViewRect = bottomRect.alignSize(pageViewSize, Alignment.bottomCenter, Alignment.topCenter);
final rotation = 0.2 * pi * sin(0.25 * pi * ctrl.value);
paintChildComposedOf(_IDS.pageView,
translate: pageViewRect.center,
anchor: pageViewSize.center(Offset.zero),
rotation: rotation,
);
final starSize = getChildSize(_IDS.star);
final t = pow(sin(pi * ctrl.value * 0.25), 2).toDouble();
final distance = pageViewSize.longestSide / 2;
paintChildComposedOf(_IDS.star,
translate: pageViewRect.center + Offset.fromDirection(-pi * ctrl.value + rotation, distance),
anchor: starSize.center(Offset.zero),
rotation: 2.123 * 2 * pi * ctrl.value,
scale: lerpDouble(2.5, 1, t)!,
opacity: lerpDouble(0.05, 0.5, t)!,
);
}
@override
BoxConstraints getConstraintsForChild(id, BoxConstraints constraints) => switch (id as _IDS) {
_IDS.top => const BoxConstraints.tightFor(width: 200),
_IDS.bottom => const BoxConstraints.tightFor(width: 125),
_IDS.star => BoxConstraints.tight(const Size.square(16)),
_IDS.pageView => BoxConstraints.tight(const Size.square(200)),
};
}
class _PageDelegate extends FlowStackLayoutDelegate {
_PageDelegate(this.index, this.pageController);
final int index;
final PageController pageController;
@override
layout(Size size) {
final offset = Offset((index - pageController.page!) * size.width * 0.75, 0);
paintChildTranslated(0, offset); // bg shadow
paintChildTranslated(1, offset); // bg shadow
paintChildTranslated(2, Offset.zero); // fg icon
}
}
// -----------------------------------------------------------------------------
class _FlowStackPageTurn extends StatefulWidget {
@override
State<_FlowStackPageTurn> createState() => _FlowStackPageTurnState();
}
class _FlowStackPageTurnState extends State<_FlowStackPageTurn> with TickerProviderStateMixin {
static final months = [
'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December',
];
final r = Random();
late final cards = List.generate(months.length, (index) {
final controller = AnimationController(vsync: this, duration: Durations.extralong4);
return (
color: HSVColor.fromAHSV(1, 360 * r.nextDouble(), lerpDouble(0.66, 1, r.nextDouble())!, 1).toColor(),
label: months[months.length - 1 - index],
controller: controller,
curvedAnimation: CurvedAnimation(
parent: controller,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
),
offset: Offset.lerp(const Offset(-50, 0), const Offset(50, -50), r.nextDouble())!,
angle: lerpDouble(-0.4, 0.4, r.nextDouble())!,
);
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme;
return FlowStack(
repaint: Listenable.merge(cards.map((card) => card.controller).toList()),
children: [
FlowStackEntry.aligned(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('click furiously fast on the bottom part of the pages to see a stream of multiple pages turning to the top', style: theme.headlineSmall),
),
),
for (final card in cards)
FlowStackEntry.animated(
animation: card.controller,
transform: (animation, childSize, parentRect) {
final m0 = FlowPaintingContextExtension.composeMatrix(
translate: parentRect.center + card.offset * card.curvedAnimation.value,
rotation: card.angle * card.curvedAnimation.value,
);
final proj = MatrixUtils.createCylindricalProjectionTransform(
perspective: 0.0015,
orientation: Axis.vertical,
radius: (animation.status == AnimationStatus.forward? 100 : -75) * sin(2 * pi * card.curvedAnimation.value),
angle: pi * card.curvedAnimation.value,
);
final m1 = FlowPaintingContextExtension.composeMatrix(
translate: -childSize.topCenter(Offset.zero),
);
return m0 * proj * m1;
},
child: SizedBox.fromSize(
key: ValueKey(card),
size: const Size(150, 250),
child: Card(
color: card.color,
child: InkWell(
splashColor: Colors.black26,
highlightColor: Colors.transparent,
onTap: () {
final controller = card.controller;
if (controller.isAnimating) return;
cards
..remove(card)
..add(card);
controller.value < 0.5? controller.forward() : controller.reverse();
setState(() {});
},
child: Stack(
children: [
Center(child: Text(card.label, style: theme.headlineSmall)),
Align(
alignment: Alignment.bottomCenter,
child: Container(
decoration: const BoxDecoration(
color: Colors.white60,
borderRadius: BorderRadius.vertical(top: Radius.circular(8))
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
child: const Text('turn page')
),
),
],
),
),
),
),
)
],
);
}
@override
void dispose() {
for (final card in cards) {
card
..controller.dispose()
..curvedAnimation.dispose();
}
super.dispose();
}
}
import 'dart:collection';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:gist_flow_painter/flow_painting_context_extension.dart';
class FlowV2 extends Flow {
FlowV2({
super.key,
required FlowV2Delegate delegate,
super.children,
super.clipBehavior,
}) : super(delegate: delegate.._updateKeyToIndexMapping(children));
FlowV2.unwrapped({
super.key,
required FlowV2Delegate delegate,
super.children,
super.clipBehavior,
}) : super.unwrapped(delegate: delegate.._updateKeyToIndexMapping(children));
@override
FlowV2Delegate get delegate => super.delegate as FlowV2Delegate;
@override
RenderFlow createRenderObject(BuildContext context) => RenderFlowV2(delegate: delegate, clipBehavior: clipBehavior);
@override
void updateRenderObject(BuildContext context, RenderFlowV2 renderObject) {
renderObject.delegate = delegate;
renderObject.clipBehavior = clipBehavior;
}
}
class RenderFlowV2 extends RenderFlow {
RenderFlowV2({
super.children,
required FlowV2Delegate delegate,
super.clipBehavior,
}) : super(delegate: delegate);
@override
FlowV2Delegate get delegate => super.delegate as FlowV2Delegate;
@override
set delegate(covariant FlowV2Delegate newDelegate) {
if (delegate == newDelegate) {
return;
}
final oldDelegate = delegate;
super.delegate = newDelegate;
if (attached) {
oldDelegate.relayout?.removeListener(markNeedsLayout);
newDelegate.relayout?.addListener(markNeedsLayout);
}
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
delegate.relayout?.addListener(markNeedsLayout);
}
@override
void detach() {
delegate.relayout?.removeListener(markNeedsLayout);
super.detach();
}
@override
void performLayout() {
super.performLayout();
size = constraints.constrain(delegate._layout(this));
}
}
abstract class FlowV2Delegate extends FlowDelegate with Diagnosticable {
FlowV2Delegate({ super.repaint, this.relayout });
/// The box constraints most recently received from the parent.
BoxConstraints get constraints => _constraints;
BoxConstraints _constraints = const BoxConstraints();
/// The flow will relayout whenever [relayout] notifies its listeners.
final Listenable? relayout;
/// This method is the main entry point for children painting.
/// See [FlowDelegate.paintChildren] for more info.
/// Returns the [Size] the associated [FlowV2] would like to have.
Size layout(bool isSizing, FlowPaintingContext context);
Size _layout(RenderFlowV2 renderFlow) {
_constraints = renderFlow.constraints;
return layout(true, _SizingFlowPaintingContext(renderFlow));
}
@override
void paintChildren(FlowPaintingContext context) => layout(false, context);
@override
Size getSize(BoxConstraints constraints) => Size.zero;
@override
bool shouldRepaint(covariant FlowDelegate oldDelegate) => false;
final Map<dynamic, int> _mapping = {};
_updateKeyToIndexMapping(List<Widget> children) {
_mapping.clear();
for (int i = 0; i < children.length; i++) {
final key = children[i].key;
if (key == null) continue;
if (key is ValueKey) {
assert(!_mapping.containsKey(key.value));
_mapping[key.value] = i;
} else
if (kDebugMode) {
final child = children[i].toStringShort();
debugPrint('$child: key $key not used in key mapping, only "ValueKey" supported');
}
}
}
/// TODO make a doc
int index(dynamic key, [bool debug = true]) {
final i = _mapping[key];
if (i != null) {
return i;
}
if (debug) _debug();
throw 'no ValueKey with |$key| found in children';
}
/// TODO make a doc
dynamic key(int index, [bool debug = true]) {
for (final entry in _mapping.entries) {
if (index == entry.value) return entry.key;
}
if (debug) _debug();
throw 'no key associated with index $index';
}
void _debug() => debugPrint(toDiagnosticsNode(name: 'delegate').toStringDeep());
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('constraints', constraints.toString()));
properties.add(IterableProperty('key-to-index mapping', _mapping.entries.map((e) => '${e.key} => ${e.value}'), style: DiagnosticsTreeStyle.shallow));
}
}
class _SizingFlowPaintingContext implements FlowPaintingContext {
_SizingFlowPaintingContext(this.base);
final FlowPaintingContext base;
@override
int get childCount => base.childCount;
@override
Size? getChildSize(int i) => base.getChildSize(i);
@override
void paintChild(int i, {Matrix4? transform, double opacity = 1.0}) {
}
@override
Size get size => base.size;
}
// =============================================================================
//
// example
//
main() {
final app = MaterialApp(
scrollBehavior: const MaterialScrollBehavior().copyWith(
dragDevices: {ui.PointerDeviceKind.mouse, ui.PointerDeviceKind.touch},
),
home: Scaffold(
body: Builder(
builder: (context) {
return ListView(
children: [
ListTile(
title: const Text('moving tabs (aka Accordion)'),
subtitle: const Text('Tabs()'),
onTap: () {
final route = MaterialPageRoute(builder: (ctx) => Scaffold(
appBar: AppBar(),
body: Tabs(),
));
Navigator.of(context).push(route);
},
),
ListTile(
title: const Text('expanding chips'),
subtitle: const Text('ExpandingChips()'),
onTap: () {
final route = MaterialPageRoute(builder: (ctx) => Scaffold(
appBar: AppBar(),
body: SingleChildScrollView(
child: Column(
children: [
'Commodo eu mollit dolor in deserunt occaecat sit.',
'Qui anim ut aliquip Lorem aute esse et minim dolor est anim quis ullamco sint.',
'Aliquip irure laboris sint esse consectetur incididunt est consectetur exercitation tempor deserunt mollit incididunt.'
].map((s) => ExpandingChips(s.split(' '))).toList(),
),
),
));
Navigator.of(context).push(route);
},
),
ListTile(
title: const Text('delegate that paints its children by "key" instead of integer'),
subtitle: const Text('Squares()'),
onTap: () {
final route = MaterialPageRoute(builder: (ctx) => Scaffold(
appBar: AppBar(),
body: const Squares(
child: Icon(Icons.alarm, size: 64),
),
));
Navigator.of(context).push(route);
},
),
],
);
}
)
),
);
runApp(app);
}
class Tabs extends StatefulWidget {
@override
State<Tabs> createState() => _TabsState();
}
const kTabHeight = 72.0;
const radius = 12.0;
class _TabsState extends State<Tabs> with TickerProviderStateMixin {
late final ctrl = AnimationController(
vsync: this,
value: 1,
duration: const Duration(milliseconds: 800),
);
int expandingTab = 2; // initial expanded tab index: 2
int shrinkingTab = -1;
final data = [
(color: Colors.red, height: 300, title: 'January'),
(color: Colors.pink, height: 100, title: 'February'),
(color: Colors.purple, height: 300, title: 'March'),
(color: Colors.deepPurple, height: 250, title: 'April'),
(color: Colors.indigo, height: 200, title: 'May'),
(color: Colors.blue, height: 300, title: 'June'),
(color: Colors.lightBlue, height: 150, title: 'July'),
(color: Colors.cyan, height: 200, title: 'August'),
(color: Colors.teal, height: 100, title: 'September'),
(color: Colors.green, height: 200, title: 'October'),
(color: Colors.lightGreen, height: 250, title: 'November'),
(color: Colors.lime, height: 200, title: 'December'),
];
@override
Widget build(BuildContext context) {
// timeDilation = 10;
print('=== build ===');
return Align(
alignment: Alignment.topCenter,
child: SingleChildScrollView(
child: FlowV2(
clipBehavior: Clip.antiAlias,
delegate: TabsDelegate(ctrl, expandingTab, shrinkingTab),
children: [
for (int i = 0; i < 12; i++)
Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: kTabHeight,
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeIn,
decoration: ShapeDecoration(
shape: TabShape(i == expandingTab? 1 : 0),
shadows: const [BoxShadow(color: Colors.black, blurRadius: 4)],
color: i == expandingTab? data[i].color.shade400 : data[i].color.shade800,
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Material(
type: MaterialType.transparency,
child: InkWell(
splashColor: Colors.orange,
highlightColor: Colors.transparent,
onTap: () {
shrinkingTab = expandingTab;
expandingTab = i != shrinkingTab? i : -1;
ctrl
..value = 0
..animateTo(1, curve: Curves.easeInOut);
setState(() {});
},
),
),
Builder(
builder: (context) {
final width = MediaQuery.of(context).size.width;
final rect = Offset.zero & Size(width, kTabHeight);
final(_, upperRect, lowerRect) = getTabGeometry(rect, 0);
final theme = Theme.of(context).textTheme;
final expanding = i == expandingTab;
final label = expanding?
'${data[i].title} sales results (including other services)' :
'${i + 1}. ${data[i].title}';
return AnimatedPositioned.fromRect(
rect: expanding? lowerRect : upperRect,
duration: const Duration(milliseconds: 400),
child: AnimatedDefaultTextStyle(
style: TextStyle(
fontWeight: expanding? FontWeight.bold : FontWeight.normal,
fontSize: expanding? theme.bodyMedium?.fontSize : theme.titleMedium?.fontSize ?? kDefaultFontSize,
color: expanding? Colors.black : Colors.white38,
),
duration: const Duration(milliseconds: 400),
child: IgnorePointer(
child: Center(
child: Text(label, softWrap: false, overflow: TextOverflow.fade),
),
),
),
);
},
),
],
),
),
),
Container(
height: data[i].height.toDouble(),
color: data[i].color.shade300,
child: Align(
alignment: Alignment.topCenter,
child: Text('container\'s height: ${data[i].height}')
),
),
],
),
],
),
),
);
}
@override
void dispose() {
super.dispose();
ctrl.dispose();
}
}
class TabsDelegate extends FlowV2Delegate {
TabsDelegate(this.ctrl, this.expandingTab, this.shrinkingTab) :
assert(expandingTab != shrinkingTab), super(relayout: ctrl);
final AnimationController ctrl;
final int expandingTab;
final int shrinkingTab;
@override
Size layout(bool isSizing, FlowPaintingContext context) {
// timeDilation = 10;
var offset = Offset.zero;
const dy = kTabHeight * 0.5;
for (int i = 0; i < context.childCount; i++) {
context.paintChildTranslated(i, offset);
Offset delta = const Offset(0, dy);
if (i == shrinkingTab || i == expandingTab) {
final t = i == shrinkingTab? 1 - ctrl.value : ctrl.value;
final height = context.getChildSize(i)!.height - kTabHeight;
delta += Offset(0, height * t);
}
offset += delta;
}
return Size(constraints.maxWidth, offset.dy + dy);
}
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return BoxConstraints.loose(constraints.biggest);
}
}
(List<Offset> points, Rect upperRect, Rect lowerRect) getTabGeometry(Rect rect, double phase) {
// r0, r1, r2: points:
// ┌─────────────────────────────────┐
// │ 1──2────────────2──1 │ 4────────────5
// │ │ │ │ │ │ / \
// 0────1──2────────────2──1─────────0 2────3 6─────────7
// │ │ │
// │ │ │
// 0─────────────────────────────────0 1───────────────────────────────0
final r0 = Rect.fromPoints(rect.centerLeft, rect.bottomRight);
final r1 = EdgeInsets.only(
left: rect.width * ui.lerpDouble(0.2, 0.05, phase)!,
top: rect.height * 0.1,
right: rect.width * 0.4,
bottom: rect.height * 0.5,
).deflateRect(rect);
final r2 = EdgeInsets.symmetric(horizontal: rect.height * 0.125).deflateRect(r1);
final points = [
r0.bottomRight, r0.bottomLeft, r0.topLeft, r1.bottomLeft,
r2.topLeft, r2.topRight, r1.bottomRight, r0.topRight,
];
return (points, r2, r0);
}
class TabShape extends ShapeBorder {
const TabShape(this.phase);
final double phase;
@override
ShapeBorder? lerpFrom(ShapeBorder? a, double t) => a is TabShape?
TabShape(ui.lerpDouble(a.phase, phase, t)!) :
super.lerpFrom(a, t);
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.zero;
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) => getOuterPath(rect);
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
final (points, _, _) = getTabGeometry(rect, phase);
// version with sharp corners
// final path = Path();
// for (final o in points) {
// o == points.first? path.moveTo(o.dx, o.dy) : path.lineTo(o.dx, o.dy);
// }
// return path;
// version with round corners
return roundPolygon(
points: points,
radii: [
0, 0, 0,
radius, radius,
radius, radius, 0,
],
);
}
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
}
@override
ShapeBorder scale(double t) => this;
}
class ExpandingChips extends StatefulWidget {
const ExpandingChips(this.words);
final Iterable<String> words;
@override
State<ExpandingChips> createState() => _ExpandingChipsState();
}
class _ExpandingChipsState extends State<ExpandingChips> with TickerProviderStateMixin {
late final ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 500));
late final animation = CurvedAnimation(parent: ctrl, curve: Curves.easeOut, reverseCurve: Curves.bounceIn);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
Widget child = FlowV2(
delegate: ExpandingChipsDelegate(constraints.biggest, animation),
children: [
...widget.words.map((s) => Padding(
padding: const EdgeInsets.all(2),
child: ActionChip(label: Text(s), onPressed: () {},),
)),
],
);
bool expanded = true;
if (ctrl.status == AnimationStatus.dismissed || ctrl.status == AnimationStatus.reverse) {
expanded = false;
child = ShaderMask(
blendMode: BlendMode.dstOut,
shaderCallback: (bounds) {
final gradientRect = Alignment.bottomCenter.inscribe(Size(constraints.maxWidth, 20), bounds);
final colors = [Colors.transparent, Colors.white];
return ui.Gradient.linear(gradientRect.topLeft, gradientRect.bottomLeft, colors);
},
child: child,
);
}
return Column(
children: [
child,
AnimatedSwitcher(
duration: const Duration(milliseconds: 1000),
child: TextButton(
key: UniqueKey(),
onPressed: () {
ctrl.value > 0.5? ctrl.reverse() : ctrl.forward();
setState(() {});
},
child: Text(expanded? 'less ⌃' : 'more ⌄'),
),
),
],
);
}
);
}
@override
void dispose() {
super.dispose();
ctrl.dispose();
}
}
class ExpandingChipsDelegate extends FlowV2Delegate {
ExpandingChipsDelegate(this.maxSize, this.animation) : super(relayout: animation);
final Size maxSize;
final Animation<double> animation;
@override
Size layout(bool isSizing, FlowPaintingContext context) {
double x = 0, y = 0, maxY = 0;
final sizes = List.generate(context.childCount, (i) => context.getChildSize(i)!);
final lineY = [];
for (int i = 0; i < context.childCount; i++) {
maxY = max(maxY, sizes[i].height);
if (x + sizes[i].width > constraints.biggest.width) {
x = 0;
y += maxY;
maxY = 0;
lineY.add(y);
}
context.paintChildTranslated(i, Offset(x, y));
x += sizes[i].width;
}
lineY.add(y + maxY);
return Size.fromHeight(ui.lerpDouble(lineY.first, lineY.last, animation.value)!);
}
}
class Squares extends StatefulWidget {
const Squares({required this.child});
final Widget child;
@override
State<Squares> createState() => _SquaresState();
}
enum ID {
red, pink, purple, teal, black, extraChild,
}
class _SquaresState extends State<Squares> with TickerProviderStateMixin {
late final ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 800));
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
Container(color: Colors.teal, height: 32),
FlowV2(
delegate: SquaresDelegate(ctrl),
children: [
const Card(key: ValueKey(ID.red), elevation: 2, color: Colors.red),
const Card(key: ValueKey(ID.pink), elevation: 6, color: Colors.pink),
const Card(key: ValueKey(ID.purple), elevation: 8, color: Colors.purple),
const Card(key: ValueKey(ID.teal), color: Colors.teal),
Card(
key: const ValueKey(ID.black),
color: Colors.black12,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
const Text('Anim eu dolore tempor reprehenderit qui exercitation exercitation reprehenderit.'),
ElevatedButton(
onPressed: () => ctrl.value < 0.5? ctrl.forward() : ctrl.reverse(),
child: const Text('press me'),
)
],
),
),
),
// if widgets are already created you can use KeyedSubtree
// to give them ValueKey
KeyedSubtree(key: const ValueKey(ID.extraChild), child: widget.child),
],
),
Container(color: Colors.indigo, height: 32),
],
),
);
}
@override
void dispose() {
super.dispose();
ctrl.dispose();
}
}
class SquaresDelegate extends FlowV2Delegate {
SquaresDelegate(this.ctrl) : super(repaint: ctrl);
final AnimationController ctrl;
@override
Size layout(bool isSizing, FlowPaintingContext context) {
final size = Size.square(constraints.biggest.shortestSide);
final rect = Offset.zero & size;
final tween0 = Tween(begin: Alignment.topLeft, end: Alignment.bottomRight);
final tween1 = Tween(begin: Alignment.topRight, end: Alignment.bottomLeft);
const length = 0.4;
_layoutAligned(context, ID.extraChild, Alignment.bottomLeft, rect);
final keys = [ID.red, ID.pink, ID.purple, ID.teal];
keys.forEachIndexed((idx, key) {
final t = idx / (keys.length - 1);
final interval = Interval(ui.lerpDouble(0, 1 - length, t)!, ui.lerpDouble(length, 1, t)!);
final i = index(key);
final size = context.getChildSize(i)!;
final t0 = Curves.easeOut.transform(interval.transform(ctrl.value));
final alignment = Alignment.lerp(tween0.transform(t), tween1.transform(t), t0)!;
context.paintChildTranslated(i, alignment.inscribe(size, rect).topLeft);
});
_layoutAligned(context, ID.black, const Alignment(0, 0.75), rect);
return size;
}
void _layoutAligned(FlowPaintingContext context, ID key, Alignment alignment, Rect rect) {
final i = index(key);
final size = context.getChildSize(i)!;
context.paintChildTranslated(i, alignment.inscribe(size, rect).topLeft);
}
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return switch (key(i)) {
ID.black => BoxConstraints(maxWidth: constraints.biggest.shortestSide / 1.9),
ID.extraChild => constraints.loosen(),
_ => BoxConstraints.tight(Size.square(constraints.biggest.shortestSide / 1.5)),
};
}
}
// -----------------------------------------------------------------------------
// stuff required for "roundPolygon"
Path roundPolygon({
required List<Offset> points,
List<double>? radii,
double? radius,
}) {
assert(
(radii == null) ^ (radius == null),
'either radii or radius has to be specified (but not both)'
);
assert(
radii == null || radii.length == points.length,
'if radii list is specified, its size has to match points list size'
);
radii ??= List.filled(points.length, radius!);
final List<Offset> absolutePoints;
if (!points.any((point) => point is _RelativeOffset)) {
// no relative [Offset] in [points]
absolutePoints = points;
} else {
// at least one relative [Offset]
Offset prevPoint = Offset.zero;
absolutePoints = points.map((point) {
return prevPoint = point is _RelativeOffset? prevPoint + point : point;
}).toList();
}
final p = absolutePoints.cycled();
final directions = p.mapIndexed((int index, Offset point) {
final delta = p[index + 1] - point;
assert(delta != Offset.zero, 'any two adjacent points have to be different');
return delta.direction;
}).toList().cycled();
final angles = p.mapIndexed((int index, Offset point) {
final nextDelta = p[index + 1] - point;
final prevDelta = p[index - 1] - point;
final angle = prevDelta.direction - nextDelta.direction;
assert(radii![index] == 0 || angle != 0);
return angle;
}).toList();
final distances = angles.mapIndexed((i, a) {
return radii![i] / sin(a / 2);
});
final path = Path();
int i = 0;
for (final distance in distances) {
if (radii[i] != 0) {
// round corner
final direction = directions[i] + angles[i] / 2;
// if normalizedAngle > pi, it means 'concave' corner
final normalizedAngle = angles[i] % (2 * pi);
var center = p[i] + Offset.fromDirection(direction, normalizedAngle < pi? distance : -distance);
final rect = Rect.fromCircle(center: center, radius: radii[i]);
final startAngle = directions[i - 1] + (normalizedAngle < pi? 1.5 * pi : -1.5 * pi);
final sweepAngle = (normalizedAngle < pi? pi : -pi) - angles[i];
path.arcTo(rect, startAngle, sweepAngle, i == 0);
} else {
// sharp corner
i == 0?
path.moveTo(p[i].dx, p[i].dy) :
path.lineTo(p[i].dx, p[i].dy);
}
i++;
}
return path..close();
}
extension RelativeOffsetExtension on Offset {
Offset get relative => _RelativeOffset(dx, dy);
}
class _RelativeOffset extends Offset {
_RelativeOffset(super.dx, super.dy);
}
extension _CyclicListExtension<T> on List<T> {
List<T> cycled() => _CyclicList<T>(this);
}
class _CyclicList<T> with ListMixin<T> {
_CyclicList(this.list);
List<T> list;
@override
int get length => list.length;
@override
set length(int newLength) => throw UnsupportedError('setting length not supported');
@override
operator [](int index) => list[index % list.length];
@override
void operator []=(int index, value) => throw UnsupportedError('indexed assignmnet not supported');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment