Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active July 29, 2024 04:39
Show Gist options
  • Save pskink/b86f5de25dd51d3b24f3994dea031357 to your computer and use it in GitHub Desktop.
Save pskink/b86f5de25dd51d3b24f3994dea031357 to your computer and use it in GitHub Desktop.
import 'dart:math';
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:flutter/rendering.dart';
typedef PaintSegmentCallback = void Function(Canvas canvas, Size size);
// PaintSegmentCallback helpers:
/// Returns [PaintSegmentCallback] for drawing dashed lines.
PaintSegmentCallback paintDashedSegment({
required Iterable<double> pattern,
required Paint paint,
}) {
return (Canvas canvas, Size size) => drawDashedLine(
canvas: canvas,
p1: size.centerLeft(Offset.zero),
p2: size.centerRight(Offset.zero),
pattern: pattern,
paint: paint,
);
}
/// Returns [PaintSegmentCallback] for drawing a clipped and transformed image.
/// You can use a handy [composeMatrix] function to build a transformation [Matrix4].
PaintSegmentCallback paintImageSegment({
required Image image,
required (Path, Matrix4) Function(Size) builder,
}) {
return (Canvas canvas, Size size) {
final (clipPath, matrix) = builder(size);
final imageShader = ImageShader(image, TileMode.repeated, TileMode.repeated, matrix.storage);
canvas
..clipPath(clipPath)
..drawPaint(Paint()..shader = imageShader);
};
}
/// Draws on a given [canvas] a sequence of 'segments' between the points from
/// [points] list.
///
/// The real work is done by [Canvas.drawAtlas] method that directly uses [colors],
/// [blendMode], [paint] and [anchor] arguments.
///
/// [height] argument specifies the segment's height.
///
/// [onPaintSegment] argument is used for drawing the longest segment which
/// converted to [ui.Image] is drawn by [Canvas.drawAtlas]. This is the most
/// important part of this function: if you draw nothing (or outside the bounds
/// defined by [Size] argument) you will see nothing.
///
/// If [close] is true, an additional segment is drawn between the last ond the
/// first point.
///
/// TODO: cache 'ui.Image atlas' between drawPolyline calls
drawPolyline({
required Canvas canvas,
required List<Offset> points,
required double height,
required PaintSegmentCallback onPaintSegment,
List<Color>? colors,
BlendMode? blendMode,
Paint? paint,
bool close = false,
Offset? anchor,
String? debugLabel,
}) {
Offset effectiveAnchor = anchor ?? Offset(0, height / 2);
final pointsList = close? [...points, points.first] : points;
final (segments, maxLength) = _segments(pointsList, effectiveAnchor);
final recorder = PictureRecorder();
final offlineCanvas = Canvas(recorder);
final segmentSize = Size(maxLength, height);
if (debugLabel != null) debugPrint('[$debugLabel]: calling onPaintSegment with $segmentSize');
onPaintSegment(offlineCanvas, segmentSize);
final picture = recorder.endRecording();
final atlas = picture.toImageSync(maxLength.ceil(), height.ceil());
final transforms = segments.mapIndexed((i, s) => RSTransform.fromComponents(
rotation: s.direction,
scale: 1, // TODO: add custom scale?
anchorX: effectiveAnchor.dx,
anchorY: effectiveAnchor.dy,
translateX: pointsList[i].dx,
translateY: pointsList[i].dy,
));
final rects = segments.map((s) => Offset.zero & Size(s.distance + effectiveAnchor.dx, height));
canvas.drawAtlas(atlas, [...transforms], [...rects], colors, blendMode, null, paint ?? Paint());
if (debugLabel != null) canvas.drawPoints(PointMode.polygon, pointsList, Paint());
}
(List<Offset>, double) _segments(List<Offset> points, Offset effectiveAnchor) {
final segments = <Offset>[];
double maxLength = 0.0;
for (int i = 0; i < points.length - 1; i++) {
final segment = points[i + 1] - points[i];
maxLength = max(maxLength, segment.distance);
segments.add(segment);
}
return (segments, maxLength + effectiveAnchor.dx);
}
Matrix4 composeMatrix({
double scale = 1,
double rotation = 0,
Offset translate = Offset.zero,
Offset anchor = Offset.zero,
}) {
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);
}
void drawDashedLine({
required Canvas canvas,
required Offset p1,
required Offset p2,
required Iterable<double> pattern,
required Paint paint,
}) {
assert(pattern.length.isEven);
final distance = (p2 - p1).distance;
final normalizedPattern = pattern.map((width) => width / distance).toList();
final points = <Offset>[];
double t = 0;
int i = 0;
while (t < 1) {
points.add(Offset.lerp(p1, p2, t)!);
t += normalizedPattern[i++]; // dashWidth
points.add(Offset.lerp(p1, p2, t.clamp(0, 1))!);
t += normalizedPattern[i++]; // dashSpace
i %= normalizedPattern.length;
}
canvas.drawPoints(PointMode.lines, points, paint);
}
import 'dart:convert';
import 'dart:math';
import 'dart:ui';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'custom_pattern_polyline.dart';
void main() => runApp(MaterialApp(home: Scaffold(body: Foo())));
class Foo extends StatefulWidget {
@override
State<Foo> createState() => _FooState();
}
class _FooState extends State<Foo> with TickerProviderStateMixin {
late final controllers = [
AnimationController(vsync: this, duration: Durations.medium4),
AnimationController(vsync: this, duration: Durations.medium4),
];
List<ui.Image>? patternImages;
@override
void initState() {
super.initState();
const patternData = [
// three sine curves
'UklGRtYDAABXRUJQVlA4TMoDAAAvL8AEEAa6sW2rtqNhiVkJqBSS1GBxYOQyeNzuh/XR7WDwnPdS'
'WFuKbW3Ltuzf/f+x6GQbDYvk0ROL2bDITnX39zzvD1QGg+RIkhTZcrOV4MEJFUC9+MQirT0FmN+n'
'icXrF+bJdbZtbbO3zLSalK10Cu3PYOWwojJzjyCcjJ6jTLEcTqzMlrdya1h9I2jbNgmAZftR3LDQ'
'I/pjocc6WCxMj3jDQiBEYRDOz/i+YvLDcCFDiYWRSAZy7pfvKww/mFzQkGKiFNGAzvn3feWcHxdc'
'hGGIA4sXOBeYkJqkpSUc0OAYTEgZ0tQiDsjEYWJCek46tAJtG6zghiTQ9ucj8ne+KQozI4m4YlVc'
'SWZmvFzPW/1VJNpSQi5ZQy4lbXvPd/5XK8VA25AEN1iQXqJcsBeCwHfH8eoJhBn5lIyZAmlrphj2'
'JBA828HJO4iWPinDVIG0HSZ7FBDta2N+sEUgfIbMJQpkMRifnec8fw1h5tEHDb/okE0xPDh0HHyE'
'aG2/Nfin7x1kPPTmEO0LD3xhr3GGvnf4ZVtehZn37+F0yXPY2OHfNr0SrYsX+Dl6lo2dM7ZPjV7f'
'dQXR0jNtbPhBE1+Y+fQGAg3etiZ/NPKJ1vUjhBHPTFk5QQt+oB85hWjJW85SWZGeMCMP4SrQBveW'
'tf+hFeoRLT3IlTAjrr/W/ieshF6gDQ90RbTkslyDtN6bNakLM1Ly3/hXTFxQCbSh9hczkPI6y1An'
'WirxM+6RCUNFmJGaf2Yg/ZPZc+qBNpT+ZpzxBJMK0VKN/zMQj0yYVA/3hPK/PQzVpWfrl/K//edU'
'D/dTmZ9xeRBmpC8rkLYnLIduoA2P9EC01KcVSFv7VmUgzBhuw02gDcPlPsxWaUC0TG5dOkcY8bep'
'lyjBC/RTRxAt+dtUM0U8YeaDuwg0+NtUpgp5RFsXrvBj9GSyDtm8YPPU6PmNW4iWnhjW9w7DpjwL'
'M+/ex8mSp3PW9w4mm/RMtLX1bpOxwyz10HuTaF+650u7djB2rkh9cUbzxSsIMw8/ssXYsS/l4V7T'
'oQeIthQw2WOuLs9NBxT+/99+skEgBBfsXaMuzx0OQhj44SheP4IwI4Fhz2/q8j3kQELBiy2cHmP1'
'V275zzQia0q07T1f2V+t5Jb/HEbB6gTa9ufDsne+yS3/X0ZidYSZGS/XshAmJhOHOCDZDH8hvcC8'
'wAkcMNjn/IXUYBoc4YBim/yFtGX6jc6oRxFRCqlLp7QDWbzkWzgLvRAFGsLQDadhZ+0w+yZn0pNI'
'GAmlK6eyAzkgzRdyxDDfYx0y3zWg',
// white-red chessboard
'UklGRigAAABXRUJQVlA4TBwAAAAvB8ABAA8w+QrSNmD8W253Jn/+w034A3xE/+MB',
];
patternData.map(base64.decode).map(decodeImageFromList).wait.then((images) {
setState(() => patternImages = images);
});
}
@override
Widget build(BuildContext context) {
const alignments = [Alignment(0, -0.85), Alignment(0, 0.25)];
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(colors: [Colors.grey.shade700, Colors.grey.shade900]),
),
child: Center(
child: AspectRatio(
aspectRatio: 9 / 16,
child: CustomPaint(
painter: FooPainter(controllers, patternImages),
child: Stack(
children: [
for (int i = 0; i < alignments.length; i++)
Align(
alignment: alignments[i],
child: FilledButton(
onPressed: () => controllers[i].value < 0.5?
controllers[i].animateTo(1, curve: Curves.ease) :
controllers[i].animateBack(0, curve: Curves.ease),
child: const Text('animate'),
),
),
],
),
),
),
),
);
}
}
class FooPainter extends CustomPainter {
FooPainter(this.animations, this.patternImages) : super(repaint: Listenable.merge(animations));
final List<Animation<double>> animations;
final List<ui.Image>? patternImages;
@override
void paint(Canvas canvas, Size size) {
// timeDilation = 10;
_drawTop(canvas, size, patternImages, animations[0].value);
_drawDashedPolygon(canvas, size, animations[1].value);
_drawSolidPolygon(canvas, size, animations[1].value);
}
@override
bool shouldRepaint(FooPainter oldDelegate) => patternImages != oldDelegate.patternImages;
void _drawTop(Canvas canvas, Size size, List<ui.Image>? patternImages, double t) {
if (patternImages != null) {
final r = lerpDouble(12.0, 8.0, t)!;
final color = Color.lerp(Colors.cyan, Colors.pink, t)!;
final height = r * 2 + 4;
for (int i = 0; i < 2; i++) {
final N = i == 0? 5 : 3;
final center = Alignment(i == 0? -0.4 : 0.4, -0.5).alongSize(size);
drawPolyline(
canvas: canvas,
points: List.generate(N, (n) => center + Offset.fromDirection(0.33 * pi * t + 2 * pi * n / N, (size.shortestSide / 2 - 90))),
height: height,
onPaintSegment: (canvas, size) {
final rect = Alignment.center.inscribe(Size(size.width, r * 2), Offset.zero & size);
final scale = 2 * r / patternImages[i].height;
final matrix = composeMatrix(
scale: i == 0? lerpDouble(1, scale, t)! : scale,
translate: rect.topLeft,
rotation: i == 0? lerpDouble(pi / 11, pi, t)! : 0,
);
final rrect = RRect.fromRectAndCorners(rect,
topLeft: Radius.circular(r),
bottomLeft: Radius.circular(r),
);
final paint = Paint()
..colorFilter = ColorFilter.mode(color, i == 0? BlendMode.modulate : BlendMode.color)
..shader = ImageShader(patternImages[i], TileMode.repeated, TileMode.repeated, matrix.storage);
canvas
..clipRRect(rrect)
..drawPaint(paint);
},
paint: Paint()..filterQuality = FilterQuality.medium,
close: true,
anchor: Offset(r, height / 2),
// debugLabel: '_drawTop',
);
}
}
}
void _drawDashedPolygon(Canvas canvas, Size size, double t) {
final center = const Alignment(0, 0.25).alongSize(size);
int N = 8;
drawPolyline(
canvas: canvas,
points: List.generate(N, (i) => center + Offset.fromDirection(-0.25 * pi * t + 2 * pi * i / N, size.shortestSide / 2 - 8)),
height: 10,
onPaintSegment: paintDashedSegment(
pattern: [lerpDouble(16, 8, t)!, lerpDouble(16, 4, t)!],
paint: Paint()..strokeWidth = lerpDouble(6, 2, t)!,
),
colors: List.generate(N, (i) => HSVColor.fromAHSV(1, 120 * i / N, 1, 0.9).toColor()),
blendMode: BlendMode.dstATop,
paint: Paint()..filterQuality = FilterQuality.medium,
close: true,
);
}
void _drawSolidPolygon(Canvas canvas, Size size, double t) {
final center = const Alignment(0, 0.25).alongSize(size);
const N = 12;
double r = lerpDouble(4.0, 8.0, t)!;
double height = r * 2 + 4;
drawPolyline(
canvas: canvas,
points: List.generate(N, (i) => center + Offset.fromDirection(0.75 * pi * t + 2 * pi * (i + 0.33) / N, (size.shortestSide / 2 - 40) * (i.isEven? 1 : lerpDouble(0.8, 1, t)!))),
height: height,
onPaintSegment: (canvas, size) {
final p1 = Offset(r, size.height / 2);
final p2 = Offset(size.width, size.height / 2);
final paint = Paint();
canvas
..drawCircle(p1, r, paint)
..drawLine(p1, p2, paint..strokeWidth = r * 2);
},
colors: List.generate(N, (i) => HSVColor.fromAHSV(1, 120 * sin(pi * i / (N - 1)), 1, 0.9).toColor()),
blendMode: BlendMode.dstATop,
paint: Paint()..filterQuality = FilterQuality.medium,
close: true,
anchor: Offset(r, height / 2),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment