Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active September 9, 2024 18:33
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);
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);
}
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