Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active November 15, 2024 19:09
Show Gist options
  • Save pskink/ea1d577d4e3a31f6fcc7e49a6c40b3d5 to your computer and use it in GitHub Desktop.
Save pskink/ea1d577d4e3a31f6fcc7e49a6c40b3d5 to your computer and use it in GitHub Desktop.
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
void main() => runApp(const MaterialApp(home: Foo()));
class Foo extends StatefulWidget {
const Foo({super.key});
@override
State<Foo> createState() => _FooState();
}
class _FooState extends State<Foo> with TickerProviderStateMixin {
late final controller = AnimationController(vsync: this, duration: Durations.long2);
final shapeNotifier = ValueNotifier(0);
final transformationController = TransformationController(FlowPaintingContextExtension.composeMatrix(
scale: 1.25,
));
final shapes = [
Shape('pink shape', Colors.pink, const Offset(175, 100), 50),
Shape('and this is multi line grey shape', Colors.grey.shade700, const Offset(75, 200), 90),
Shape('green shape', Colors.green.shade900, const Offset(50, 350), 75),
Shape('indigo shape', Colors.indigo, const Offset(25, 50), 70),
];
late Tween<int> tween = Tween(begin: shapes.length - 1, end: shapes.length - 1);
bool slowMotion = false;
bool useFlowWidget = true;
@override
void initState() {
super.initState();
SchedulerBinding.instance.addPostFrameCallback(_showInfo);
}
void _showInfo(_) => showDialog(
context: context,
builder: (_) {
return SimpleDialog(
contentPadding: const EdgeInsets.all(12),
backgroundColor: Colors.grey,
children: [
const Text('press any shape and then try zooming in / out the InteractiveViewer (or scroll it in any direction) - you will see that the bottom label doesn\'t change its size'),
const Divider(),
const Text('you can switch between Flow / Custom[Multi|Single]ChildLayout layouts by clicking "render options" action button (note that Flow layout gives more options for children rendering)'),
ElevatedButton(
onPressed: Navigator.of(context).pop,
child: const Text('ok'),
),
],
);
}
);
@override
Widget build(BuildContext context) {
timeDilation = slowMotion? 10 : 1;
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
onPressed: _showOptions,
label: const Text('render options'),
),
body: Stack(
children: [
InteractiveViewer(
transformationController: transformationController,
constrained: false,
minScale: 1,
maxScale: 10,
child: _interactiveViewerChild(),
),
_buildNonScaledLabel(),
],
),
);
}
Widget _interactiveViewerChild() {
return ConstrainedBox(
// those constraints will be used by ShapesFlowDelegate
constraints: BoxConstraints.loose(const Size(320, 640)),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.teal.shade700, Colors.indigo.shade900],
),
),
child: DecoratedBox(
decoration: const FlutterLogoDecoration(),
child: useFlowWidget? Flow(
delegate: ShapesFlowDelegate(shapes, shapeNotifier),
children: List.generate(shapes.length, _buildShape),
) : CustomMultiChildLayout(
delegate: ShapesDelegate(shapes, shapeNotifier),
children: List.generate(shapes.length, _buildShapeWithId),
),
),
),
);
}
Widget _buildNonScaledLabel() {
final shape = shapes[tween.end!];
final label = AnimatedContainer(
duration: controller.duration!,
constraints: BoxConstraints(maxWidth: shape.maxWidth),
decoration: BoxDecoration(
color: shape.color,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(6)),
boxShadow: const [BoxShadow(color: Colors.black87, blurRadius: 2, offset: Offset(0, 2))],
),
padding: const EdgeInsets.all(4),
child: ShapeLabel(
shape: shape,
shapeNotifier: shapeNotifier,
tc: transformationController,
),
);
return useFlowWidget? Flow(
delegate: LabelFlowDelegate(shapes, tween, transformationController, controller, shapeNotifier),
children: [label],
) : CustomSingleChildLayout(
delegate: LabelDelegate(shapes, tween, transformationController, controller, shapeNotifier),
child: label,
);
}
Widget _buildShape(int i) {
final textTheme = Theme.of(context).textTheme;
final shape = shapes[i];
return KeyedSubtree(
key: ObjectKey(shape),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 150),
child: CustomPaint(
painter: BottomCenterAlignmentPainter(),
child: Container(
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: shape.color, width: 3)),
),
child: Card(
clipBehavior: Clip.antiAlias,
color: shape.color,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: const BorderSide(color: Colors.white38),
),
child: InkWell(
splashColor: Colors.black38,
onTap: () {
final oldShape = shapes[tween.end!];
final newShape = shapes.removeAt(i);
shapes.add(newShape);
setState(() => tween = Tween(
begin: shapes.indexOf(oldShape),
end: shapes.indexOf(newShape)), // should be always shapes.lenght - 1 but....
);
controller
..value = 0
..animateTo(1, curve: Curves.easeOut);
},
child: Padding(
padding: const EdgeInsets.all(6),
child: Text(shape.label,
style: textTheme.titleMedium?.copyWith(color: Colors.white70),
),
),
),
),
),
),
),
);
}
Widget _buildShapeWithId(int i) => LayoutId(id: i, child: _buildShape(i));
_showOptions() {
showDialog(
context: context,
builder: (BuildContext context) {
return SimpleDialog(
children: [
CheckboxListTile(
title: const Text('use slow motion animations (10x slower)'),
value: slowMotion,
onChanged: (v) {
setState(() => slowMotion = v!);
Navigator.of(context).pop();
},
),
CheckboxListTile(
title: const Text('use Flow widget layouts'),
value: useFlowWidget,
onChanged: (v) {
setState(() => useFlowWidget = v!);
Navigator.of(context).pop();
},
),
],
);
},
);
}
}
class ShapeLabel extends StatefulWidget {
const ShapeLabel({
required this.shape,
required this.shapeNotifier,
required this.tc,
super.key,
});
final Shape shape;
final ValueNotifier<int> shapeNotifier;
final TransformationController tc;
@override
State<ShapeLabel> createState() => _ShapeLabelState();
}
class _ShapeLabelState extends State<ShapeLabel> {
double heightFactor = 1;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onPanStart: (d) => setState(() => heightFactor = 0),
onPanUpdate: (d) {
widget.shape.offset += d.delta / widget.tc.value.getMaxScaleOnAxis();
widget.shapeNotifier.value++;
},
onPanEnd: (d) => setState(() => heightFactor = 1),
child: const Icon(Icons.drag_indicator), // or pan_tool_outlined / open_with ?
),
ClipRect(
child: AnimatedAlign(
alignment: Alignment.bottomCenter,
duration: Durations.medium2,
heightFactor: heightFactor,
child: Text('${widget.shape.label} label',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
),
),
],
);
}
}
class BottomCenterAlignmentPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..strokeWidth = 1
..color = Colors.black26;
final bottomCenter = size.bottomCenter(Offset.zero) + Offset(0, paint.strokeWidth / 2);
final dx = Offset(size.width * 0.75, 0);
final dy = Offset(0, size.height);
canvas
..drawLine(bottomCenter - dx, bottomCenter + dx, paint)
..drawLine(bottomCenter - dy * 1.25, bottomCenter + dy * 2, paint);
}
@override
bool shouldRepaint(BottomCenterAlignmentPainter oldDelegate) => false;
}
// =========================== delegates
class ShapesDelegate extends MultiChildLayoutDelegate {
ShapesDelegate(this.shapes, Listenable shapeNotifier) : super(relayout: shapeNotifier);
final List<Shape> shapes;
@override
void performLayout(Size size) {
// print('performLayout');
final constraints = BoxConstraints.loose(size);
for (int i = 0; i < shapes.length; i++) {
shapes[i].size = layoutChild(i, constraints);
positionChild(i, shapes[i].offset);
}
}
@override
bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) => false;
}
class ShapesFlowDelegate extends FlowDelegate {
ShapesFlowDelegate(this.shapes, Listenable shapeNotifier) : super(repaint: shapeNotifier);
final List<Shape> shapes;
@override
void paintChildren(FlowPaintingContext context) {
// print('ShapesFlowDelegate.paintChildren');
for (int i = 0; i < shapes.length; i++) {
shapes[i].size = context.getChildSize(i)!;
context.paintChildTranslated(i, shapes[i].offset);
}
}
@override
bool shouldRepaint(covariant FlowDelegate oldDelegate) => false;
}
class LabelDelegate extends SingleChildLayoutDelegate {
LabelDelegate(this.shapes, this.tween, this.tc, this.ac, Listenable shapeNotifier) :
super(relayout: Listenable.merge([tc, ac, shapeNotifier]));
final List<Shape> shapes;
final Tween<int> tween;
final TransformationController tc;
final AnimationController ac;
@override
Offset getPositionForChild(Size size, Size childSize) {
// print('getPositionForChild');
final offsets = [tween.begin, tween.end].map((idx) {
final shape = shapes[idx!];
final offset = shape.size.bottomCenter(shape.offset);
return MatrixUtils.transformPoint(tc.value, offset) - childSize.topCenter(Offset.zero);
}).toList();
return MaterialPointArcTween(begin: offsets[0], end: offsets[1]).transform(ac.value);
// return Offset.lerp(offsets[0], offsets[1], ac.value)!;
}
@override
bool shouldRelayout(covariant SingleChildLayoutDelegate oldDelegate) => true;
}
class LabelFlowDelegate extends FlowDelegate {
LabelFlowDelegate(this.shapes, this.tween, this.tc, this.ac, Listenable shapeNotifier) :
super(repaint: Listenable.merge([tc, ac, shapeNotifier]));
final List<Shape> shapes;
final Tween<int> tween;
final TransformationController tc;
final AnimationController ac;
@override
void paintChildren(FlowPaintingContext context) {
// print('LabelFlowDelegate.paintChildren');
final childSize = context.getChildSize(0)!;
final offsets = [tween.begin, tween.end].map((idx) {
final shape = shapes[idx!];
final offset = shape.size.bottomCenter(shape.offset);
return MatrixUtils.transformPoint(tc.value, offset) - childSize.topCenter(Offset.zero);
}).toList();
final offset = MaterialPointArcTween(begin: offsets[0], end: offsets[1]).transform(ac.value);
// final offset = Offset.lerp(offsets[0], offsets[1], ac.value)!;
// context.paintChildTranslated(0, offset);
context.paintChildComposedOf(0,
anchor: childSize.centerRight(Offset.zero),
translate: childSize.centerRight(offset),
scale: 1 + sin(pi * ac.value) * 2,
rotation: pi * 0.1 * sin(pi * 1 * ac.value),
opacity: 1 - sin(pi * ac.value) * 0.5,
);
}
@override
bool shouldRepaint(covariant FlowDelegate oldDelegate) => true;
}
class Shape {
Shape(this.label, this.color, this.offset, this.maxWidth);
final String label;
final Color color;
Offset offset;
final double maxWidth;
Size size = Size.zero;
}
// =============================================================================
//
// 2 useful extensions
//
// =============================================================================
extension FlowPaintingContextExtension on FlowPaintingContext {
/// Paints the [i]th child using [translate] to position the child.
paintChildTranslated(int i, Offset translate, { double opacity = 1.0 }) => paintChild(i,
transform: composeMatrix(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 [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://github.com/pskink/flow_stack/blob/main/images/transform_composing.png?raw=true)
///
/// 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,
);
/// 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,
);
}
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) {
// a special case:
// c = cos(rotation) * scale => scale
// s = sin(rotation) * scale => 0
// it reduces to:
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 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:path_drawing/path_drawing.dart';
void main() => runApp(const MaterialApp(home: Foo()));
class Foo extends StatefulWidget {
const Foo({super.key});
@override
State<Foo> createState() => _FooState();
}
class _FooState extends State<Foo> with TickerProviderStateMixin {
late final controller = AnimationController(vsync: this, duration: Durations.long2);
final labelNotifier = ValueNotifier(0);
final shapeNotifier = ValueNotifier(0);
final transformationController = TransformationController(Matrix4.diagonal3Values(1.25, 1.25, 1.0));
late Tween<int> tween = Tween(begin: shapes.length - 1, end: shapes.length - 1);
bool slowMotion = false;
@override
void initState() {
super.initState();
SchedulerBinding.instance.addPostFrameCallback(_showInfo);
}
void _showInfo(_) => showDialog(
context: context,
builder: (_) {
return SimpleDialog(
contentPadding: const EdgeInsets.all(12),
backgroundColor: Colors.grey,
children: [
const Text('you can move both color shapes and the labels attached to them\n\nnote that after zooming in / out the label\'s alignment does not change'),
ElevatedButton(
onPressed: Navigator.of(context).pop,
child: const Text('ok'),
),
],
);
}
);
@override
Widget build(BuildContext context) {
timeDilation = slowMotion? 10 : 1;
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
onPressed: _showOptions,
label: const Text('render options'),
),
body: Stack(
children: [
InteractiveViewer(
transformationController: transformationController,
constrained: false,
minScale: .25,
maxScale: 10,
child: _interactiveViewerChild(),
),
_nonScaledLabel(),
],
),
);
}
Widget _interactiveViewerChild() {
return ConstrainedBox(
// those constraints will be used by ShapesFlowDelegate
constraints: BoxConstraints.loose(const Size(320, 640)),
child: DecoratedBox(
decoration: const FlutterLogoDecoration(),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.black, Colors.blue.shade900.withOpacity(0.75)],
),
),
child: CustomMultiChildLayout(
delegate: ShapesDelegate(shapes, shapeNotifier),
children: List.generate(shapes.length, _buildShapeWithId),
),
),
),
);
}
Widget _nonScaledLabel() {
return CustomSingleChildLayout(
delegate: LabelDelegate(shapes, tween, transformationController, controller, labelNotifier, shapeNotifier),
child: ShapeLabel(
shape: shapes[tween.end!],
labelNotifier: labelNotifier,
tc: transformationController,
ac: controller,
),
);
}
Widget _buildShape(int i) {
final shape = shapes[i];
final isTop = shape == shapes.last;
return KeyedSubtree(
key: ObjectKey(shape),
child: SizedBox.fromSize(
size: shape.path.getBounds().size * 4 * shape.scale,
child: CustomPaint(
foregroundPainter: BoundsPainter(isTop),
child: Material(
clipBehavior: Clip.antiAlias,
color: shape.color,
shape: PathBorder(shape.path),
child: InkWell(
splashColor: Colors.black38,
onTap: () {
final oldShape = shapes[tween.end!];
final newShape = shapes.removeAt(i);
shapes.add(newShape);
setState(() => tween = Tween(
begin: shapes.indexOf(oldShape),
end: shapes.indexOf(newShape)), // should be always shapes.length - 1 but....
);
controller
..value = 0
..animateTo(1, curve: Curves.easeOut);
},
child: GestureDetector(
onPanUpdate: (d) {
shape.offset += d.delta;
shapeNotifier.value++;
},
),
),
),
),
),
);
}
Widget _buildShapeWithId(int i) => LayoutId(id: i, child: _buildShape(i));
_showOptions() {
showDialog(
context: context,
builder: (BuildContext context) {
return SimpleDialog(
insetPadding: const EdgeInsets.all(6),
children: [
CheckboxListTile(
title: const Text('use slow motion animations (10x slower)'),
value: slowMotion,
onChanged: (v) {
setState(() => slowMotion = v!);
Navigator.of(context).pop();
},
),
],
);
},
);
}
}
class BoundsPainter extends CustomPainter {
BoundsPainter(this.isTop);
final bool isTop;
@override
void paint(Canvas canvas, Size size) {
if (isTop) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0
..color = Colors.white30;
canvas.drawRect(Offset.zero & size, paint);
}
}
@override
bool shouldRepaint(BoundsPainter oldDelegate) => false;
}
class PathBorder extends ShapeBorder {
const PathBorder(this.path);
final Path path;
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.zero;
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) => getOuterPath(rect);
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
final matrix = sizeToRect(path.getBounds().size, rect);
return path.transform(matrix.storage);
}
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
}
@override
ShapeBorder scale(double t) => this;
}
class ShapeLabel extends StatefulWidget {
const ShapeLabel({
required this.shape,
required this.labelNotifier,
required this.tc,
required this.ac,
super.key,
});
final Shape shape;
final ValueNotifier<int> labelNotifier;
final TransformationController tc;
final AnimationController ac;
@override
State<ShapeLabel> createState() => _ShapeLabelState();
}
class _ShapeLabelState extends State<ShapeLabel> {
double heightFactor = 1;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: widget.ac.duration!,
constraints: const BoxConstraints(maxWidth: 80),
decoration: BoxDecoration(
color: Color.alphaBlend(Colors.black26, widget.shape.color),
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(6)),
boxShadow: const [BoxShadow(color: Colors.black87, blurRadius: 2, offset: Offset(0, 2))],
),
padding: const EdgeInsets.all(4),
child: AnimatedSize(
duration: widget.ac.duration!,
curve: Curves.ease,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onPanStart: (d) => setState(() => heightFactor = 0),
onPanUpdate: (d) {
final shape = widget.shape;
final zoom = widget.tc.value.getMaxScaleOnAxis();
final scaledChildSize = context.size! / zoom;
final shapeRect = Offset.zero & shape.size;
final labelRect = shape.labelAlignment.inscribe(scaledChildSize, shapeRect);
final alignment = getChildAlignment(
parentRect: shapeRect,
childRect: labelRect.shift(d.delta / zoom),
clampX: (-1, 1), // clamp x
clampY: (-1, 1), // clamp y
);
shape.labelAlignment = alignment;
widget.labelNotifier.value++;
},
onPanEnd: (d) => setState(() => heightFactor = 1),
child: const Icon(Icons.drag_indicator), // or pan_tool_outlined / open_with ?
),
ClipRect(
child: AnimatedAlign(
alignment: Alignment.bottomCenter,
duration: Durations.medium2,
heightFactor: heightFactor,
child: Text('${widget.shape.label} label',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
),
),
],
),
),
);
}
}
// =========================== delegates
class ShapesDelegate extends MultiChildLayoutDelegate {
ShapesDelegate(this.shapes, Listenable shapeNotifier) : super(relayout: shapeNotifier);
final List<Shape> shapes;
@override
void performLayout(Size size) {
// print('performLayout');
final constraints = BoxConstraints.loose(size);
for (int i = 0; i < shapes.length; i++) {
shapes[i].size = layoutChild(i, constraints);
positionChild(i, shapes[i].offset);
}
}
@override
bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) => false;
}
class LabelDelegate extends SingleChildLayoutDelegate {
LabelDelegate(this.shapes, this.tween, this.tc, this.ac, Listenable labelNotifier, Listenable shapeNotifier) :
super(relayout: Listenable.merge([tc, ac, labelNotifier, shapeNotifier]));
final List<Shape> shapes;
final Tween<int> tween;
final TransformationController tc;
final AnimationController ac;
@override
Offset getPositionForChild(Size size, Size childSize) {
// print('getPositionForChild');
final offsets = [tween.begin, tween.end].map((idx) {
final zoom = tc.value.getMaxScaleOnAxis();
final shape = shapes[idx!];
final scaledChildSize = childSize / zoom;
final shapeRect = Offset.zero & shape.size;
final labelRect = shape.labelAlignment.inscribe(scaledChildSize, shapeRect);
return MatrixUtils.transformPoint(tc.value, shape.offset + labelRect.topLeft);
}).toList();
return MaterialPointArcTween(begin: offsets[0], end: offsets[1]).transform(ac.value);
// return Offset.lerp(offsets[0], offsets[1], ac.value)!;
}
@override
bool shouldRelayout(covariant SingleChildLayoutDelegate oldDelegate) => true;
}
Alignment getChildAlignment({
required Rect parentRect,
required Rect childRect,
(double, double)? clampX,
(double, double)? clampY,
}) {
double normalize(double value, double begin, double end) {
return 2 * (value - begin) / (end - begin) - 1;
}
final dx = (parentRect.width - childRect.width) / 2;
final dy = (parentRect.height - childRect.height) / 2;
final parentCenter = parentRect.center;
final childCenter = childRect.center;
final x = normalize(childCenter.dx, parentCenter.dx - dx, parentCenter.dx + dx);
final y = normalize(childCenter.dy, parentCenter.dy - dy, parentCenter.dy + dy);
return Alignment(
clampX != null? x.clamp(clampX.$1, clampX.$2) : x,
clampY != null? y.clamp(clampY.$1, clampY.$2) : y,
);
}
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);
}
class Shape {
Shape(this.label, String pathData, this.color, this.offset, {
this.labelAlignment = Alignment.center,
this.scale = 1,
}) : path = parseSvgPathData(pathData);
final String label;
final Color color;
Offset offset;
Size size = Size.zero;
Alignment labelAlignment;
double scale;
Size labelSize = Size.zero;
final Path path;
}
final shapes = [
Shape('green apple', data[0], const Color(0xff00aa00), const Offset(100, 25), labelAlignment: Alignment.bottomCenter, scale: 1.25),
Shape('cocktail', data[2], Colors.blue.shade300, const Offset(20, 325)),
Shape('tulip', data[3], Colors.red.shade900, const Offset(25, 90), scale: 0.75),
Shape('key', data[4], Colors.grey.shade700, const Offset(105, 150), labelAlignment: Alignment.bottomLeft, scale: 1.5),
Shape('music', data[5], Colors.pink, const Offset(125, 350)),
Shape('cctv camera', data[1], Colors.amber, const Offset(25, 175), labelAlignment: Alignment.centerLeft),
];
final data = [
'M19.4 7.03c-3.11 1.35-3.5.86-6.4.97V6A10 10 0 0 0 11.43.47L11.2.05l-1.75.96.27.48A8 8 0 0 1 11 6v.4C5.55 4.57-.07 9.1 0 15c-.1 6.33 6.42 10.95 12 8.2 10.46 4.42 16.8-10.76 7.4-16.17m.13-2.54A6.7 6.7 0 0 1 15 6c-.12-3.77 2.16-6.19 6-6a6.4 6.4 0 0 1-1.47 4.49',
'm20.96 11.35 3.05-2.18L8.3.37a3 3 0 0 0-4.06 1.2L2.42 4.9A3 3 0 0 0 3.6 8.96l6.05 3.43L8 17.32a1 1 0 0 1-.95.68H2v-4H0v10h2v-4h5.06a3 3 0 0 0 2.85-2.05l1.51-4.55 6.43 3.64 1-1.82 2.2 1.22 2.01-3.69-2.2-1.22z',
'M22.92 0h-.22zm0 0c2.28.1 3.68 2.92 2.5 4.75a722 722 0 0 1-8.75 8.18q.8.7 1.81.94V22h.38c1.32.01 1.32 2 0 2h5.62c1.33 0 1.33-1.99 0-2h-4v-8.13q1.06-.25 1.87-.98l8.17-7.55C32.6 3.56 31-.11 28.32 0zM3.15 0A3.17 3.17 0 0 0 .1 4h16.87a1 1 0 0 1 0 2H1.66l7.46 6.9a4 4 0 0 0 1.86.97V22h-4a1 1 0 0 0 0 2h10a1 1 0 0 0 0-2h-4v-8.13a4 4 0 0 0 1.87-.98l8.17-7.55c2.08-1.78.48-5.45-2.2-5.34z',
'M23.54 14.73A2 2 0 0 0 21.8 14a10.4 10.4 0 0 0-8.8 5.95v-6.03A6 6 0 0 0 18 8c0-2.8-1.94-5.15-3.84-7.1L13.8.6A17 17 0 0 0 12 7.97a1 1 0 1 1-2 .06 19 19 0 0 1 1.85-8 3 3 0 0 0-2 .88C7.95 2.85 6 5.2 6 8a6 6 0 0 0 5 5.92v6.03a10.4 10.4 0 0 0-8.8-5.94 2 2 0 0 0-1.73.71 2 2 0 0 0-.4 1.8C2.11 23.86 11.6 24 12 24h.02c.4 0 9.88-.14 11.92-7.48a2 2 0 0 0-.4-1.8',
'm13.67.19-1.3.6a3 3 0 0 0-1.55 1.7L6.71 13.82a7.52 7.52 0 1 0 6.22 2.26l1.16-3.19-1.56-3.34 2.72-1.27-1.27-2.72 3.21-1.5a3 3 0 0 0-.25-1.6l-.61-1.3a2 2 0 0 0-2.66-.97M7.2 25.29a1.5 1.5 0 1 1 .72-1.99 1.5 1.5 0 0 1-.72 2',
'M22.55.92a4 4 0 0 0-3.29-.85l-9.18 1.72A5 5 0 0 0 6 6.71v9.85A4 4 0 0 0 4 16a4 4 0 1 0 4 4v-9.12A2 2 0 0 1 9.63 8.9l11.18-2.1a1 1 0 0 1 1.19 1v5.76a4 4 0 0 0-2-.56 4 4 0 1 0 4 4V4A4 4 0 0 0 22.55.92',
];
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:path_drawing/path_drawing.dart';
// this version uses just one delegate that paints [shapes.length] shape widgets
// plus 2 labels
//
// those labels are children of InteractiveViewer but they always stay
// the same size - this is done by using [scale: ...] when calling [context.paintChildComposedOf]
void main() => runApp(const MaterialApp(home: Foo()));
class Foo extends StatefulWidget {
const Foo({super.key});
@override
State<Foo> createState() => _FooState();
}
class _FooState extends State<Foo> with TickerProviderStateMixin {
late final controller = AnimationController(vsync: this, duration: Durations.long2, value: 1);
final shapeNotifier = ValueNotifier(0);
final transformationController = TransformationController(Matrix4.diagonal3Values(1.25, 1.25, 1.0));
late Tween<int> tween = Tween(begin: shapes.length - 1, end: shapes.length - 1);
bool slowMotion = false;
@override
Widget build(BuildContext context) {
timeDilation = slowMotion? 10 : 1;
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
onPressed: _showOptions,
label: const Text('render options'),
),
body: InteractiveViewer(
transformationController: transformationController,
constrained: false,
minScale: .25,
maxScale: 10,
child: _interactiveViewerChild(),
),
);
}
Widget _interactiveViewerChild() {
return ConstrainedBox(
// those constraints will be used by ShapesFlowDelegate
constraints: BoxConstraints.loose(const Size(320, 640)),
child: DecoratedBox(
decoration: const FlutterLogoDecoration(),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.black, Colors.blue.shade900.withOpacity(0.75)],
),
),
child: Flow(
delegate: ShapesDelegate(shapes, tween, shapeNotifier, transformationController, controller),
children: [
...Iterable.generate(shapes.length, _buildShape),
// fading-out label
IgnorePointer(
child: ShapeLabel(
shape: shapes[tween.begin!],
shapeNotifier: shapeNotifier,
tc: transformationController,
ac: controller,
),
),
// fading-in label
ShapeLabel(
shape: shapes[tween.end!],
shapeNotifier: shapeNotifier,
tc: transformationController,
ac: controller,
),
],
),
),
),
);
}
Widget _buildShape(int i) {
final shape = shapes[i];
final isTop = shape == shapes.last;
return KeyedSubtree(
key: ObjectKey(shape),
child: SizedBox.fromSize(
size: shape.path.getBounds().size * 4 * shape.scale,
child: CustomPaint(
foregroundPainter: BoundsPainter(isTop),
child: Material(
clipBehavior: Clip.antiAlias,
color: shape.color,
shape: PathBorder(shape.path),
child: InkWell(
splashColor: Colors.black38,
onTap: () {
final oldShape = shapes[tween.end!];
final newShape = shapes.removeAt(i);
shapes.add(newShape);
setState(() => tween = Tween(
begin: shapes.indexOf(oldShape),
end: shapes.indexOf(newShape)), // should be always shapes.length - 1 but....
);
controller.forward(from: 0);
},
child: GestureDetector(
onPanUpdate: (d) {
shape.offset += d.delta;
shapeNotifier.value++;
},
),
),
),
),
),
);
}
_showOptions() {
showDialog(
context: context,
builder: (BuildContext context) {
return SimpleDialog(
insetPadding: const EdgeInsets.all(6),
children: [
CheckboxListTile(
title: const Text('use slow motion animations (10x slower)'),
value: slowMotion,
onChanged: (v) {
setState(() => slowMotion = v!);
Navigator.of(context).pop();
},
),
],
);
},
);
}
}
class BoundsPainter extends CustomPainter {
BoundsPainter(this.isTop);
final bool isTop;
@override
void paint(Canvas canvas, Size size) {
if (isTop) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0
..color = Colors.white30;
canvas.drawRect(Offset.zero & size, paint);
}
}
@override
bool shouldRepaint(BoundsPainter oldDelegate) => false;
}
class PathBorder extends ShapeBorder {
const PathBorder(this.path);
final Path path;
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.zero;
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) => getOuterPath(rect);
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
final matrix = sizeToRect(path.getBounds().size, rect);
return path.transform(matrix.storage);
}
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
}
@override
ShapeBorder scale(double t) => this;
}
class ShapeLabel extends StatefulWidget {
const ShapeLabel({
required this.shape,
required this.shapeNotifier,
required this.tc,
required this.ac,
super.key,
});
final Shape shape;
final ValueNotifier<int> shapeNotifier;
final TransformationController tc;
final AnimationController ac;
@override
State<ShapeLabel> createState() => _ShapeLabelState();
}
class _ShapeLabelState extends State<ShapeLabel> {
double heightFactor = 1;
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 80),
decoration: BoxDecoration(
color: Color.alphaBlend(Colors.black26, widget.shape.color),
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(6)),
boxShadow: const [BoxShadow(color: Colors.black87, blurRadius: 2, offset: Offset(0, 2))],
),
padding: const EdgeInsets.all(4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onPanStart: (d) => setState(() => heightFactor = 0),
onPanUpdate: (d) {
final shape = widget.shape;
final zoom = widget.tc.value.getMaxScaleOnAxis();
final scaledChildSize = context.size! / zoom;
final shapeRect = Offset.zero & shape.size;
final labelRect = shape.labelAlignment.inscribe(scaledChildSize, shapeRect);
final alignment = getChildAlignment(
parentRect: shapeRect,
childRect: labelRect.shift(d.delta / zoom),
clampX: (-1, 1), // clamp x
clampY: (-1, 1), // clamp y
);
shape.labelAlignment = alignment;
widget.shapeNotifier.value++;
},
onPanEnd: (d) => setState(() => heightFactor = 1),
child: const Icon(Icons.drag_indicator), // or pan_tool_outlined / open_with ?
),
ClipRect(
child: AnimatedAlign(
alignment: Alignment.bottomCenter,
duration: Durations.medium2,
heightFactor: heightFactor,
child: Text('${widget.shape.label} label',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
),
),
],
),
);
}
}
class ShapesDelegate extends FlowDelegate {
ShapesDelegate(this.shapes, this.tween, Listenable shapeNotifier, this.tc, this.ac) :
super(repaint: Listenable.merge([shapeNotifier, tc, ac]));
final List<Shape> shapes;
final Tween<int> tween;
final TransformationController tc;
final AnimationController ac;
@override
void paintChildren(FlowPaintingContext context) {
// paint shapes
for (int i = 0; i < shapes.length; i++) {
shapes[i].size = context.getChildSize(i)!;
context.paintChildTranslated(i, shapes[i].offset);
}
(Offset t, Offset a) _ta(Shape shape, Size labelSize) {
final alignment = shape.labelAlignment;
final translation = shape.offset + alignment.alongSize(shape.size);
final anchor = alignment.alongSize(labelSize);
return (translation, anchor);
}
final zoom = tc.value.getMaxScaleOnAxis();
final tweens = List.generate(2, (i) {
final labelSize = context.getChildSize(shapes.length + i)!;
final (t0, a0) = _ta(shapes[tween.begin!], labelSize);
final (t1, a1) = _ta(shapes[tween.end!], labelSize);
return (
translate: MaterialPointArcTween(begin: t0, end: t1),
anchor: MaterialPointArcTween(begin: a0, end: a1),
);
});
// paint 2 labels
for (int i = 0; i < 2; i++) {
context.paintChildComposedOf(shapes.length + i,
translate: tweens[i].translate.transform(ac.value),
anchor: tweens[i].anchor.transform(ac.value),
scale: (i == 0? 1 : lerpDouble(0.5, 1, ac.value)!) / zoom,
opacity: Curves.easeOut.transform(i == 0? 1 - ac.value : ac.value),
);
}
}
@override
bool shouldRepaint(covariant FlowDelegate oldDelegate) => false;
}
Alignment getChildAlignment({
required Rect parentRect,
required Rect childRect,
(double, double)? clampX,
(double, double)? clampY,
}) {
double normalize(double value, double begin, double end) {
return 2 * (value - begin) / (end - begin) - 1;
}
final dx = (parentRect.width - childRect.width) / 2;
final dy = (parentRect.height - childRect.height) / 2;
final parentCenter = parentRect.center;
final childCenter = childRect.center;
final x = normalize(childCenter.dx, parentCenter.dx - dx, parentCenter.dx + dx);
final y = normalize(childCenter.dy, parentCenter.dy - dy, parentCenter.dy + dy);
return Alignment(
clampX != null? x.clamp(clampX.$1, clampX.$2) : x,
clampY != null? y.clamp(clampY.$1, clampY.$2) : y,
);
}
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);
}
class Shape {
Shape(this.label, String pathData, this.color, this.offset, {
this.labelAlignment = Alignment.center,
this.scale = 1,
}) : path = parseSvgPathData(pathData);
final String label;
final Color color;
Offset offset;
Size size = Size.zero;
Alignment labelAlignment;
double scale;
Size labelSize = Size.zero;
final Path path;
}
final shapes = [
Shape('green apple', data[0], const Color(0xff00aa00), const Offset(100, 25), labelAlignment: Alignment.bottomCenter, scale: 1.25),
Shape('cocktail', data[2], Colors.blue.shade300, const Offset(20, 325)),
Shape('tulip', data[3], Colors.red.shade900, const Offset(25, 90), scale: 0.75),
Shape('key', data[4], Colors.grey.shade700, const Offset(105, 150), labelAlignment: Alignment.bottomLeft, scale: 1.5),
Shape('music', data[5], Colors.pink, const Offset(125, 350)),
Shape('cctv camera', data[1], Colors.amber, const Offset(25, 175), labelAlignment: Alignment.centerLeft),
];
final data = [
'M19.4 7.03c-3.11 1.35-3.5.86-6.4.97V6A10 10 0 0 0 11.43.47L11.2.05l-1.75.96.27.48A8 8 0 0 1 11 6v.4C5.55 4.57-.07 9.1 0 15c-.1 6.33 6.42 10.95 12 8.2 10.46 4.42 16.8-10.76 7.4-16.17m.13-2.54A6.7 6.7 0 0 1 15 6c-.12-3.77 2.16-6.19 6-6a6.4 6.4 0 0 1-1.47 4.49',
'm20.96 11.35 3.05-2.18L8.3.37a3 3 0 0 0-4.06 1.2L2.42 4.9A3 3 0 0 0 3.6 8.96l6.05 3.43L8 17.32a1 1 0 0 1-.95.68H2v-4H0v10h2v-4h5.06a3 3 0 0 0 2.85-2.05l1.51-4.55 6.43 3.64 1-1.82 2.2 1.22 2.01-3.69-2.2-1.22z',
'M22.92 0h-.22zm0 0c2.28.1 3.68 2.92 2.5 4.75a722 722 0 0 1-8.75 8.18q.8.7 1.81.94V22h.38c1.32.01 1.32 2 0 2h5.62c1.33 0 1.33-1.99 0-2h-4v-8.13q1.06-.25 1.87-.98l8.17-7.55C32.6 3.56 31-.11 28.32 0zM3.15 0A3.17 3.17 0 0 0 .1 4h16.87a1 1 0 0 1 0 2H1.66l7.46 6.9a4 4 0 0 0 1.86.97V22h-4a1 1 0 0 0 0 2h10a1 1 0 0 0 0-2h-4v-8.13a4 4 0 0 0 1.87-.98l8.17-7.55c2.08-1.78.48-5.45-2.2-5.34z',
'M23.54 14.73A2 2 0 0 0 21.8 14a10.4 10.4 0 0 0-8.8 5.95v-6.03A6 6 0 0 0 18 8c0-2.8-1.94-5.15-3.84-7.1L13.8.6A17 17 0 0 0 12 7.97a1 1 0 1 1-2 .06 19 19 0 0 1 1.85-8 3 3 0 0 0-2 .88C7.95 2.85 6 5.2 6 8a6 6 0 0 0 5 5.92v6.03a10.4 10.4 0 0 0-8.8-5.94 2 2 0 0 0-1.73.71 2 2 0 0 0-.4 1.8C2.11 23.86 11.6 24 12 24h.02c.4 0 9.88-.14 11.92-7.48a2 2 0 0 0-.4-1.8',
'm13.67.19-1.3.6a3 3 0 0 0-1.55 1.7L6.71 13.82a7.52 7.52 0 1 0 6.22 2.26l1.16-3.19-1.56-3.34 2.72-1.27-1.27-2.72 3.21-1.5a3 3 0 0 0-.25-1.6l-.61-1.3a2 2 0 0 0-2.66-.97M7.2 25.29a1.5 1.5 0 1 1 .72-1.99 1.5 1.5 0 0 1-.72 2',
'M22.55.92a4 4 0 0 0-3.29-.85l-9.18 1.72A5 5 0 0 0 6 6.71v9.85A4 4 0 0 0 4 16a4 4 0 1 0 4 4v-9.12A2 2 0 0 1 9.63 8.9l11.18-2.1a1 1 0 0 1 1.19 1v5.76a4 4 0 0 0-2-.56 4 4 0 1 0 4 4V4A4 4 0 0 0 22.55.92',
];
extension FlowPaintingContextExtension on FlowPaintingContext {
/// Paints the [i]th child using [translate] to position the child.
paintChildTranslated(int i, Offset translate, { double opacity = 1.0 }) => paintChild(i,
transform: composeMatrix(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 [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://github.com/pskink/flow_stack/blob/main/images/transform_composing.png?raw=true)
///
/// 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,
);
/// 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,
);
}
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) {
// a special case:
// c = cos(rotation) * scale => scale
// s = sin(rotation) * scale => 0
// it reduces to:
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);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment