Last active
July 29, 2024 04:39
-
-
Save pskink/b86f5de25dd51d3b24f3994dea031357 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math'; | |
import 'dart:ui'; | |
import 'package:collection/collection.dart'; | |
import 'package:flutter/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); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart: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