Last active
September 9, 2024 18:33
-
-
Save pskink/b86f5de25dd51d3b24f3994dea031357 to your computer and use it in GitHub Desktop.
This file contains hidden or 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); | |
const kSequenceSeparator = _SeparatingOffset(); | |
// 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); | |
}; | |
} | |
/// Flatten input [list] so that it can be used in [drawPolyline]. | |
List<Offset> multiSequenceAdapter(List<List<Offset>> list) { | |
return [...list.expandIndexed((i, l) => [if (i != 0) kSequenceSeparator, ...l])]; | |
} | |
/// Draws on a given [canvas] a sequence of 'segments' between the points from | |
/// [points] list. | |
/// | |
/// If you want to draw multiple sequences use [multiSequenceAdapter]: | |
/// | |
/// ```dart | |
/// drawPolyline( | |
/// canvas: canvas, | |
/// points: multiSequenceAdapter([ | |
/// [p0, p1, p2], // sequence #1 | |
/// [p3, p4], // sequence #2 | |
/// ... | |
/// ]), | |
/// ... | |
/// ); | |
/// ``` | |
/// | |
/// 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); | |
points = close? [...points, points.first] : points; | |
final (segments, translations, maxLength) = _segments(points, effectiveAnchor); | |
if (colors != null && colors.isNotEmpty && colors.length != segments.length) { | |
throw ArgumentError('If non-null, "colors" length must match that of "segments".\n' | |
'colors.length: ${colors.length}, segments.length: ${segments.length}'); | |
} | |
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: translations[i].dx, | |
translateY: translations[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, points, Paint()); | |
} | |
(List<Offset>, List<Offset>, double) _segments(List<Offset> points, Offset effectiveAnchor) { | |
final segments = <Offset>[]; | |
final translations = <Offset>[]; | |
double maxLength = 0.0; | |
for (int i = 0; i < points.length - 1; i++) { | |
final p0 = points[i]; | |
final p1 = points[i + 1]; | |
if (p0 is _SeparatingOffset || p1 is _SeparatingOffset) { | |
continue; | |
} | |
final segment = p1 - p0; | |
maxLength = max(maxLength, segment.distance); | |
segments.add(segment); | |
translations.add(p0); | |
} | |
return (segments, translations, maxLength + effectiveAnchor.dx); | |
} | |
class _SeparatingOffset extends Offset { | |
const _SeparatingOffset() : super(double.nan, double.nan); | |
} | |
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 hidden or 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:async'; | |
import 'dart:convert'; | |
import 'dart:math'; | |
import 'dart:ui' as ui; | |
import 'dart:ui'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:collection/collection.dart'; | |
import 'package:flutter/scheduler.dart'; | |
import 'custom_pattern_polyline.dart'; | |
void main() => runApp(MaterialApp( | |
home: Scaffold(body: StartUpMenu()), | |
routes: <String, WidgetBuilder>{ | |
'single': (_) => Demo(label: 'single sequence', child: SingleSequence()), | |
'multi': (_) => Demo(label: 'multi sequence', child: MultiSequence()), | |
'tiles': (_) => Demo(label: 'tile display', child: TileDisplay()), | |
}, | |
)); | |
class StartUpMenu extends StatelessWidget { | |
final demos = [ | |
('single sequence', 'two animated examples of single polygons', 'single'), | |
('multi sequence', 'three spirals drawn in one call', 'multi'), | |
('tile display', 'multiple tile display showing fade-in / fade-out effects', 'tiles'), | |
]; | |
@override | |
Widget build(BuildContext context) { | |
return ListView( | |
children: [ | |
if (kIsWeb) Card( | |
color: Colors.cyan.shade200, | |
child: Padding( | |
padding: const EdgeInsets.all(8), | |
child: Text('on flutter web platform, make sure you are using "canvaskit" or "skwasm" renderer ("--web-renderer ..." option), otherwise no example will work when using "html" renderer', style: Theme.of(context).textTheme.titleLarge), | |
), | |
), | |
...demos.map((r) => ListTile( | |
title: Text(r.$1), | |
subtitle: Text(r.$2), | |
onTap: () => Navigator.of(context).pushNamed(r.$3), | |
)), | |
], | |
); | |
} | |
} | |
class Demo extends StatelessWidget { | |
const Demo({super.key, required this.child, required this.label}); | |
final Widget child; | |
final String label; | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar(backgroundColor: Colors.grey.shade600, title: Text(label)), | |
body: DecoratedBox( | |
decoration: BoxDecoration( | |
gradient: LinearGradient(colors: [Colors.grey.shade700, Colors.grey.shade900]), | |
), | |
child: child, | |
), | |
); | |
} | |
} | |
// ----------------------------------------------------------------------------- | |
class SingleSequence extends StatefulWidget { | |
@override | |
State<SingleSequence> createState() => _SingleSequenceState(); | |
} | |
class _SingleSequenceState extends State<SingleSequence> 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.90), Alignment(0, 0.25)]; | |
return Center( | |
child: AspectRatio( | |
aspectRatio: 9 / 16, | |
child: CustomPaint( | |
painter: SingleSequencePainter(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'), | |
), | |
), | |
], | |
), | |
), | |
), | |
); | |
} | |
@override | |
void dispose() { | |
controllers.forEach(_disposeController); | |
super.dispose(); | |
} | |
void _disposeController(AnimationController ac) => ac.dispose(); | |
} | |
class SingleSequencePainter extends CustomPainter { | |
SingleSequencePainter(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(SingleSequencePainter 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), | |
); | |
} | |
} | |
// ----------------------------------------------------------------------------- | |
class MultiSequence extends StatefulWidget { | |
@override | |
State<MultiSequence> createState() => _MultiSequenceState(); | |
} | |
class _MultiSequenceState extends State<MultiSequence> with TickerProviderStateMixin { | |
late final controller = AnimationController.unbounded(vsync: this, duration: Durations.medium4); | |
@override | |
Widget build(BuildContext context) { | |
return Column( | |
crossAxisAlignment: CrossAxisAlignment.stretch, | |
children: [ | |
Expanded( | |
child: CustomPaint( | |
painter: MultiSequencePainter(controller), | |
), | |
), | |
Container( | |
height: 100, | |
color: Colors.grey, | |
child: GestureDetector( | |
onHorizontalDragUpdate: (d) => controller.value += d.primaryDelta!, | |
onHorizontalDragEnd: (d) { | |
final simulation = ClampingScrollSimulation( | |
position: controller.value, | |
velocity: d.primaryVelocity!, | |
friction: 0.01, | |
); | |
controller.animateWith(simulation); | |
}, | |
behavior: HitTestBehavior.opaque, | |
child: const Center( | |
child: Padding( | |
padding: EdgeInsets.all(8.0), | |
child: Text('drag here (left / right) to see rotating spirals or fling your finger after dragging'), | |
), | |
), | |
), | |
), | |
], | |
); | |
} | |
@override | |
void dispose() { | |
controller.dispose(); | |
super.dispose(); | |
} | |
} | |
class MultiSequencePainter extends CustomPainter { | |
MultiSequencePainter(this.animation) : super(repaint: animation) { | |
final r = Random(); | |
factors = List.generate(3, (i) => r.nextDouble() * (r.nextBool()? 1 : -1)); | |
debugPrint('factors: $factors'); | |
} | |
final Animation<double> animation; | |
late final List<double> factors; | |
@override | |
void paint(Canvas canvas, Size size) { | |
final center = size.center(Offset.zero); | |
final t = 0.5 - cos(2 * pi * animation.value / (2 * size.width)) / 2; | |
final a = 2 * pi * animation.value / size.width; | |
const N = 80; | |
drawPolyline( | |
canvas: canvas, | |
points: multiSequenceAdapter([ | |
[...spiral(a * factors[0], center, N, 8)], | |
[...spiral(a * factors[1] + 1 / 3 * 2 * pi, center, N, lerpDouble(8, 4, t)!)], | |
[...spiral(a * factors[2] + 2 / 3 * 2 * pi, center, N, lerpDouble(8, 12, t)!)], | |
]), | |
height: 12, | |
colors: [ | |
...Iterable.generate(N - 1, (i) => Color.lerp(Colors.orange, Colors.pink, t)!), | |
...Iterable.generate(N - 1, (i) => Colors.green.shade700), | |
...Iterable.generate(N - 1, (i) => Color.lerp(Colors.red.shade800, Colors.indigo, t)!), | |
], | |
blendMode: BlendMode.dstATop, | |
onPaintSegment: (c, s) { | |
final rrect = RRect.fromRectAndRadius(Offset.zero & s, Radius.circular(s.height / 2)); | |
c.drawRRect(rrect, Paint()); | |
}, | |
); | |
} | |
@override | |
bool shouldRepaint(covariant CustomPainter oldDelegate) => false; | |
} | |
Iterable<Offset> spiral(double startAngle, Offset center, int numPoints, double b) sync* { | |
const lineLength = 16; | |
double angle = -0.5; | |
for (int i = 0; i < numPoints; i++) { | |
final r = b * angle; | |
// double da = lineLength / sqrt(1 + r * r); | |
// double da = lineLength / r.abs(); | |
double da = atan2(lineLength, r); | |
angle += da; | |
yield center + Offset.fromDirection(startAngle + angle, b * angle); | |
} | |
} | |
// ----------------------------------------------------------------------------- | |
final symbols = '🥑AB🥕CDEFG❤️HIJK🍋LMNO🍐PQR🍓STUV🍉WXYZ'.characters; | |
class TileDisplay extends StatefulWidget { | |
@override | |
State<TileDisplay> createState() => _TileDisplayState(); | |
} | |
class _TileDisplayState extends State<TileDisplay> with TickerProviderStateMixin { | |
List<Glyph>? glyphs; | |
var glyphSize = Size.zero; | |
late final controller = AnimationController.unbounded(vsync: this, duration: Durations.long2); | |
final indices = ValueNotifier(Tween(begin: 0, end: 0)); | |
int index = 0; | |
ImageFilterType imageFilter = ImageFilterType.dilate; | |
double randomizeFactor = 0.6; | |
TileType tileType = TileType.square; | |
List<({double x, double y})> angles = []; | |
@override | |
void initState() { | |
super.initState(); | |
symbols.mapIndexed(_makeGlyph).wait.then((allGlyphs) { | |
final r = Random(); | |
glyphs = allGlyphs; | |
glyphSize = Size( | |
maxBy(allGlyphs, (g) => g.height)!.height.toDouble(), | |
maxBy(allGlyphs, (g) => g.width)!.width.toDouble(), | |
); | |
final side = glyphSize.longestSide; | |
final N = (side * (side + 1)).toInt(); | |
angles = List.generate(N, (_) => (x: r.nextDouble() * pi, y: r.nextDouble() * pi)); | |
setState(() {}); | |
}); | |
} | |
final colors = [ | |
Colors.grey, Colors.white, Colors.yellow, Colors.orange, Colors.deepPurple, | |
]; | |
Future<Glyph> _makeGlyph(int i, String s) async { | |
debugPrint('making data for: $s'); | |
final tp = TextPainter( | |
text: TextSpan( | |
text: s, | |
style: TextStyle( | |
fontSize: 17, | |
color: colors[i % colors.length], | |
fontWeight: FontWeight.w600, // Semi-bold | |
), | |
), | |
textDirection: TextDirection.ltr, | |
); | |
final recorder = PictureRecorder(); | |
final canvas = Canvas(recorder); | |
tp | |
..layout() | |
..paint(canvas, Offset.zero); | |
final image = recorder.endRecording().toImageSync(tp.size.width.ceil(), tp.size.height.ceil()); | |
final data = (await image.toByteData(format: ImageByteFormat.rawRgba))!; | |
return Glyph(data, tp.computeLineMetrics().first); | |
} | |
@override | |
Widget build(BuildContext context) { | |
if (glyphs == null) return const UnconstrainedBox(); | |
return Center( | |
child: ConstrainedBox( | |
constraints: const BoxConstraints(maxWidth: 1.5 * 320), | |
child: SingleChildScrollView( | |
child: Material( | |
type: MaterialType.transparency, | |
child: ListTileTheme( | |
data: ListTileThemeData( | |
dense: true, | |
contentPadding: const EdgeInsets.symmetric(horizontal: 8), | |
textColor: Colors.black87, | |
tileColor: Colors.grey, | |
shape: RoundedRectangleBorder( | |
borderRadius: BorderRadius.circular(6), | |
), | |
), | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
AspectRatio( | |
aspectRatio: 1, | |
child: Stack( | |
children: [ | |
Positioned.fill( | |
child: CustomPaint( | |
painter: TileDisplayPainter( | |
animation: controller, | |
indices: indices, | |
tileType: tileType, | |
imageFilter: imageFilter, | |
randomizeFactor: randomizeFactor, | |
glyphs: glyphs!, | |
glyphSize: glyphSize, | |
angles: angles, | |
), | |
), | |
), | |
Align( | |
alignment: Alignment.bottomLeft, | |
child: IconButton.filled( | |
onPressed: () { | |
final i = index--; | |
index = index % symbols.length; | |
indices.value = Tween(begin: i, end: index); | |
controller.animateTo(index.toDouble()); | |
}, | |
icon: const Icon(Icons.keyboard_arrow_left), | |
), | |
), | |
Align( | |
alignment: Alignment.bottomRight, | |
child: IconButton.filled( | |
onPressed: () { | |
final i = index++; | |
index = index % symbols.length; | |
indices.value = Tween(begin: i, end: index); | |
controller.animateTo(index.toDouble()); | |
}, | |
icon: const Icon(Icons.keyboard_arrow_right), | |
), | |
), | |
], | |
), | |
), | |
const SizedBox(height: 4), | |
const Text('press left / right buttons above', style: TextStyle(color: Colors.white70)), | |
const SizedBox(height: 4), | |
ListTile( | |
title: Row( | |
children: [ | |
const Text('tile type'), | |
const Expanded(child: UnconstrainedBox()), | |
DropdownButton<TileType>( | |
items: [ | |
...TileType.values.map((tt) => DropdownMenuItem(value: tt, child: Text(tt.name))), | |
], | |
value: tileType, | |
onChanged: (v) => setState(() => tileType = v!) | |
), | |
], | |
), | |
), | |
const SizedBox(height: 4), | |
ListTile( | |
title: Row( | |
children: [ | |
const Text('image filter'), | |
const Expanded(child: UnconstrainedBox()), | |
DropdownButton<ImageFilterType>( | |
items: [ | |
...ImageFilterType.values.map((ift) => DropdownMenuItem(value: ift, child: Text(ift.name))), | |
], | |
value: imageFilter, | |
onChanged: (v) => setState(() => imageFilter = v!) | |
), | |
], | |
), | |
), | |
const SizedBox(height: 4), | |
ListTile( | |
title: Row( | |
children: [ | |
const Text('randomize'), | |
Expanded( | |
child: Slider( | |
value: randomizeFactor, | |
divisions: 5, | |
onChanged: (v) => setState(() => randomizeFactor = v), | |
), | |
), | |
], | |
), | |
), | |
], | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
@override | |
void dispose() { | |
controller.dispose(); | |
indices.dispose(); | |
super.dispose(); | |
} | |
} | |
enum TileType { | |
square, circle, star, | |
} | |
enum ImageFilterType { | |
dilate, erode, blur, none, | |
} | |
class TileDisplayPainter extends CustomPainter { | |
TileDisplayPainter({ | |
required this.animation, | |
required this.indices, | |
required this.tileType, | |
required this.imageFilter, | |
required this.randomizeFactor, | |
required this.glyphs, | |
required this.glyphSize, | |
required this.angles, | |
}) : super(repaint: animation); | |
final AnimationController animation; | |
final ValueNotifier<Tween<int>> indices; | |
final TileType tileType; | |
final ImageFilterType imageFilter; | |
final double randomizeFactor; | |
final List<Glyph> glyphs; | |
final Size glyphSize; | |
final List<({double x, double y})> angles; | |
final r = Random(); | |
@override | |
void paint(ui.Canvas canvas, ui.Size size) { | |
assert(size.aspectRatio == 1); | |
final side = glyphSize.longestSide; | |
if (animation.isAnimating) { | |
for (int i = 0; i < angles.length; i++) { | |
final a = angles[i]; | |
angles[i] = (x: a.x + r.nextDouble() * pi / 4, y: a.y + r.nextDouble() * pi / 6); | |
} | |
} | |
Paint paint = Paint(); | |
try { | |
paint.imageFilter = switch (imageFilter) { | |
ImageFilterType.dilate => ui.ImageFilter.dilate(radiusX: 1, radiusY: 1), | |
ImageFilterType.erode => ui.ImageFilter.erode(radiusX: 1, radiusY: 1), | |
ImageFilterType.blur => ui.ImageFilter.blur(sigmaX: 1, sigmaY: 1), | |
ImageFilterType.none => null, | |
}; | |
} catch(e) { | |
// ImageFilter not supported | |
} | |
drawPolyline( | |
canvas: canvas, | |
points: [..._generateGrid(side, size)], | |
height: size.width / side, | |
anchor: Offset.zero, | |
blendMode: BlendMode.dstATop, | |
colors: [..._generateColors(side, indices.value, animation.value)], | |
paint: paint, | |
onPaintSegment: (canvas, size) { | |
switch (tileType) { | |
case TileType.square: canvas.drawRect(Offset.zero & size, Paint()); | |
case TileType.circle: canvas.drawOval(Offset.zero & size, Paint()); | |
case TileType.star: | |
final r = (Offset.zero & size).inflate(size.shortestSide * 0.125); | |
const ShapeDecoration(color: Colors.black, shape: StarBorder(points: 5)) | |
.createBoxPainter(() {}) | |
.paint(canvas, r.topLeft, ImageConfiguration(size: r.size)); | |
} | |
}, | |
); | |
} | |
@override | |
bool shouldRepaint(covariant CustomPainter oldDelegate) => true; | |
Iterable<Offset> _generateGrid(double side, Size size) sync* { | |
// timeDilation = 10; | |
int i = 0; | |
for (int y = 0; y < side; y++) { | |
for (int x = 0; x <= side; x++) { | |
final a = angles[i]; | |
final dx = randomizeFactor != 0? randomizeFactor * 5 * sin(a.x) : 0; | |
final dy = randomizeFactor != 0? randomizeFactor * 8 * sin(a.y) : 0; | |
yield Offset(size.width * x / side + dx, size.height * y / side + dy); | |
i++; | |
} | |
yield kSequenceSeparator; // mark the sequence completed | |
} | |
} | |
Iterable<Color> _generateColors(double side, Tween<int> indices, double animationValue) sync* { | |
final (glyph0, r0) = _glyph(indices.begin!, side); | |
final (glyph1, r1) = _glyph(indices.end!, side); | |
final span = (indices.end! - indices.begin!).abs(); | |
for (int y = 0; y < side; y++) { | |
for (int x = 0; x < side; x++) { | |
final opacity0 = ((animationValue - indices.end!).abs() / span).clamp(0.0, 1.0); | |
final color0 = _color(x, y, glyph0, r0, opacity0); | |
final opacity1 = ((animationValue - indices.begin!).abs() / span).clamp(0.0, 1.0); | |
final color1 = _color(x, y, glyph1, r1, opacity1); | |
yield Color.alphaBlend(color1, color0); | |
} | |
} | |
} | |
Color _color(int x, int y, Glyph glyph, Rect r, double opacity) { | |
if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { | |
final (alpha, rgb) = glyph.alphaRgb(x - r.left.toInt(), y - r.top.toInt()); | |
// don't use Color.withOpacity() to avoid extra allocations | |
return Color((alpha * opacity).round() << 24 | rgb); | |
} | |
return Colors.transparent; | |
} | |
(Glyph, Rect) _glyph(int index, double side) { | |
final glyph = glyphs[index]; | |
final rect = Alignment.center.inscribe(glyph.size, Offset.zero & Size(side, side)); | |
return (glyph, rect.shift(rect.topLeft % 1)); | |
} | |
} | |
class Glyph { | |
Glyph(ByteData bytes, this.metrics) : | |
data = decodeRgbaBytes(bytes), | |
width = metrics.width.ceil(), | |
height = metrics.height.ceil(), | |
size = Size(metrics.width.ceilToDouble(), metrics.height.ceilToDouble()); | |
final List<(int, int)> data; | |
final LineMetrics metrics; | |
final int width; | |
final int height; | |
final Size size; | |
(int, int) alphaRgb(int x, int y) { | |
if (x >= width || y >= height) return (0, 0); | |
return data[y * width + x]; | |
} | |
} | |
List<(int, int)> decodeRgbaBytes(ByteData data) { | |
final list = <(int, int)>[]; | |
int i = 0; | |
final bytes = data.buffer.asUint8List(); | |
while (i < bytes.length) { | |
final (r, g, b, alpha) = (bytes[i++], bytes[i++], bytes[i++], bytes[i++]); | |
list.add((alpha, r << 16 | g << 8 | b)); | |
} | |
return list; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment