Last active
September 22, 2021 00:14
-
-
Save roipeker/074ea4f8c5a8fd192a6a69da63b60a90 to your computer and use it in GitHub Desktop.
Simple Flutter particles with Stack + Transform
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
/// 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); |
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
/// 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