Skip to content

Instantly share code, notes, and snippets.

@roipeker
Last active September 22, 2021 00:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save roipeker/074ea4f8c5a8fd192a6a69da63b60a90 to your computer and use it in GitHub Desktop.
Save roipeker/074ea4f8c5a8fd192a6a69da63b60a90 to your computer and use it in GitHub Desktop.
Simple Flutter particles with Stack + Transform
/// roipeker 2021
/// live demo at:
/// https://roi-particles-manu.surge.sh
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:vector_math/vector_math_64.dart' hide Matrix4, Colors;
class SampleManu extends StatefulWidget {
const SampleManu({Key? key}) : super(key: key);
@override
_SampleManuState createState() => _SampleManuState();
}
class _SampleManuState extends State<SampleManu>
with SingleTickerProviderStateMixin {
late final Ticker ticker = createTicker(_onTick);
void _onTick(d) {
system.update();
setState(() {});
}
@override
void initState() {
ticker.start();
super.initState();
}
final system = ParticleSystem(40);
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (e) {
system.emit = true;
system.positionEmitter(e.localPosition);
},
onPanUpdate: (e) => system.positionEmitter(e.localPosition),
onPanEnd: (e) {
system.emit = false;
},
child: SizedBox.expand(
child: Stack(
children: system.particleWidgets,
),
),
);
}
}
class ParticleSystem {
late List<Particle> particles;
Vector2 emitter = Vector2(0, 0);
bool emit = false;
ParticleSystem(int numParticles) {
particles =
List.generate(numParticles, (index) => Particle(index, emitter));
}
void update() {
for (final p in particles) {
if (p.active) {
p.update();
}
if (p.isDead) {
p.active = false;
}
if (emit && !p.active) {
p.active = true;
p.reset();
}
}
}
List<Widget> get particleWidgets {
return particles.where((e) => e.active).map((e) {
return Transform(
transform: e.matrix,
alignment: Alignment.center,
child: RepaintBoundary.wrap(
Opacity(
opacity: e.lifePercent,
child: Text(
'manu',
style: TextStyle(
fontSize: 20,
color: e.color,
),
),
),
e.index,
),
);
}).toList(growable: false);
}
void positionEmitter(Offset position) {
emitter.setValues(position.dx, position.dy);
}
}
class Particle {
final Matrix4 _matrix = Matrix4.identity();
late double rotation, scale, velRot;
final int index;
final pos = Vector2(0, 0);
final vel = Vector2(0, 0);
final acc = Vector2(0, 0);
final Vector2 emitter;
late int _lifespan;
late int _startLifespan;
late Color color;
bool active = true;
Particle(this.index, this.emitter) {
reset();
}
void reset() {
emitter.copyInto(pos);
acc.setValues(0, .7);
vel.setValues(
randomRange(-5, 5),
randomRange(-14, -8),
);
color = randomList(Colors.primaries);
_lifespan = randomRangeInt(60, 120);
_startLifespan = _lifespan;
rotation = randomRange(-0.3, 0.3);
velRot = randomRange(-.3, .6);
scale = randomRange(0.75, 1.25);
}
Matrix4 get matrix {
_matrix.setIdentity();
_matrix.translate(pos.x, pos.y, 0.0);
_matrix.scale(scale, scale, 1.0);
_matrix.rotateZ(rotation);
return _matrix;
}
bool get isDead => _lifespan < 0;
double get lifePercent => _lifespan / _startLifespan;
void update() {
rotation += velRot;
vel.add(acc);
pos.add(vel);
_lifespan--;
}
}
final _rnd = Random();
T randomList<T>(Iterable<T> list) {
final index = randomRangeInt(0, list.length - 1);
return list.elementAt(index);
}
int randomRangeInt(int min, int max) => min + _rnd.nextInt(max - min);
double randomRange(double min, double max) =>
min + _rnd.nextDouble() * (max - min);
/// roipeker 2021
/// same version with CustomPainter instead of Widgets.
/// uses the `ParticleSystem`
/// live demo at:
/// https://roi-particles-manu-painter.surge.sh/
/// new demo (emoji image @ 300 particles)
/// https://roi-particles-manu-painter2.surge.sh/
/// emoji image: https://roi-particles-manu-painter2.surge.sh/assets/assets/emoji.png
class SampleManuWithCustomPainter extends StatefulWidget {
const SampleManuWithCustomPainter({Key? key}) : super(key: key);
@override
_SampleManuWithCustomPainterState createState() =>
_SampleManuWithCustomPainterState();
}
class _SampleManuWithCustomPainterState
extends State<SampleManuWithCustomPainter>
with SingleTickerProviderStateMixin {
late final controller =
AnimationController(vsync: this, duration: const Duration(seconds: 1))
..repeat();
final system = ParticleSystem(30);
ui.Image? emojiTexture;
@override
void initState() {
loadImage('assets/emoji.png').then((image) {
setState(() {
emojiTexture = image;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (e) {
system.emit = true;
system.positionEmitter(e.localPosition);
},
onPanUpdate: (e) => system.positionEmitter(e.localPosition),
onPanEnd: (e) {
system.emit = false;
},
child: RepaintBoundary(
child: CustomPaint(
painter: ParticlePaint(
controller,
system,
emojiTexture,
),
),
),
),
);
}
}
class ParticlePaint extends CustomPainter {
/// we might save some performance keeping it static.
static final TextPainter _textPainter = TextPainter(
text: const TextSpan(
text: 'manu',
style: TextStyle(fontSize: 20, color: Colors.blue),
),
textDirection: TextDirection.ltr,
)..layout();
static Offset? _textOffset;
Offset get textOffset {
/// "center" the text bounding box for the rotation.
return _textOffset ??=
Offset(-_textPainter.width / 2, -_textPainter.height / 2);
}
final ParticleSystem system;
final ui.Image? texture;
const ParticlePaint(
Listenable repaint,
this.system,
this.texture,
) : super(repaint: repaint);
@override
void paint(Canvas canvas, Size size) {
system.update();
final fill = Paint();
// fill.color = Colors.blue;
var textureOffset = Offset.zero;
if (texture != null) {
textureOffset = Offset(-texture!.width / 2, -texture!.height / 2);
}
// textPainter.layout();
final activeParticles = system.particles.where((p) => p.active);
for (final p in activeParticles) {
canvas.save();
canvas.transform(p.matrix.storage);
if (texture == null) {
_textPainter.paint(canvas, textOffset);
} else {
/// draw image.
canvas.scale(.25, .25);
/// we only use color for the alpha
fill.color = Colors.blue.withOpacity(p.lifePercent);
canvas.drawImage(texture!, textureOffset, fill);
}
// fill.color = Colors.blue.withOpacity(p.lifePercent);
// canvas.drawRect(const Rect.fromLTWH(-10, -10, 20, 20), fill);
canvas.restore();
}
}
@override
bool shouldRepaint(covariant ParticlePaint oldDelegate) => true;
@override
bool shouldRebuildSemantics(covariant ParticlePaint oldDelegate) => false;
}
Future<ui.Image> loadImage(String assetId) async {
final completer = Completer<ui.Image>();
final bytes = await rootBundle.load(assetId);
ui.decodeImageFromList(
bytes.buffer.asUint8List(),
(result) => completer.complete(result),
);
return completer.future;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment