Created
May 10, 2025 03:28
-
-
Save callmephil/caaee27784b9aecc331379948988ebef to your computer and use it in GitHub Desktop.
3D Particle Cloud (Gemini)
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 'package:flutter/material.dart'; | |
// Main function to run the Flutter application | |
void main() { | |
runApp(const ParticleApp()); | |
} | |
// Root widget of the application | |
class ParticleApp extends StatelessWidget { | |
const ParticleApp({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
title: '3D Particle Cloud Visualizer', | |
theme: ThemeData( | |
primarySwatch: Colors.blue, | |
brightness: Brightness.dark, | |
sliderTheme: SliderThemeData( | |
activeTrackColor: Colors.cyanAccent.withOpacity(0.7), | |
inactiveTrackColor: Colors.grey.withOpacity(0.5), | |
thumbColor: Colors.cyanAccent, | |
overlayColor: Colors.cyanAccent.withAlpha(0x29), | |
valueIndicatorColor: Colors.cyanAccent, | |
activeTickMarkColor: Colors.transparent, | |
inactiveTickMarkColor: Colors.transparent, | |
), | |
), | |
home: const ParticleVisualizerScreen(), | |
debugShowCheckedModeBanner: false, | |
); | |
} | |
} | |
// Represents a single particle in the 3D cloud | |
class Particle { | |
Offset positionXY; | |
double positionZ; | |
Offset velocityXY; | |
double velocityZ; | |
Color color; | |
double radius; | |
double lifespan; | |
double initialLifespan; | |
Particle({ | |
required this.positionXY, | |
required this.positionZ, | |
required this.velocityXY, | |
required this.velocityZ, | |
required this.color, | |
required this.radius, | |
required this.lifespan, | |
}) : initialLifespan = lifespan; | |
void update( | |
Size bounds, | |
Offset? attractorPointXY, | |
double attractorPointZ, | |
bool repel, | |
double focalDepth, | |
) { | |
positionXY += velocityXY; | |
positionZ += velocityZ; | |
velocityXY *= 0.98; | |
velocityZ *= 0.98; | |
if (attractorPointXY != null) { | |
double dx = attractorPointXY.dx - positionXY.dx; | |
double dy = attractorPointXY.dy - positionXY.dy; | |
double dz = attractorPointZ - positionZ; | |
double distanceSq = dx * dx + dy * dy + dz * dz; | |
double distance = sqrt(distanceSq); | |
if (distance > 1.0) { | |
double forceFactor = 1.0 / (distanceSq + 10.0); | |
double forceStrength = repel ? -75.0 : 50.0; | |
velocityXY += Offset( | |
dx * forceFactor * forceStrength, | |
dy * forceFactor * forceStrength, | |
); | |
velocityZ += dz * forceFactor * forceStrength; | |
} | |
} | |
double worldWidth = bounds.width * 1.5; | |
double worldHeight = bounds.height * 1.5; | |
if (positionXY.dx < -worldWidth / 2) | |
positionXY = Offset(worldWidth / 2, positionXY.dy); | |
if (positionXY.dx > worldWidth / 2) | |
positionXY = Offset(-worldWidth / 2, positionXY.dy); | |
if (positionXY.dy < -worldHeight / 2) | |
positionXY = Offset(positionXY.dx, worldHeight / 2); | |
if (positionXY.dy > worldHeight / 2) | |
positionXY = Offset(positionXY.dx, -worldHeight / 2); | |
double zBoundary = focalDepth * 2; | |
if (positionZ > zBoundary || positionZ < -zBoundary * 0.5) { | |
velocityZ *= -0.8; | |
positionZ = positionZ.clamp(-zBoundary * 0.5, zBoundary); | |
} | |
} | |
bool isAlive() => lifespan > 0; | |
} | |
class ParticleVisualizerScreen extends StatefulWidget { | |
const ParticleVisualizerScreen({super.key}); | |
@override | |
State<ParticleVisualizerScreen> createState() => | |
_ParticleVisualizerScreenState(); | |
} | |
class _ParticleVisualizerScreenState extends State<ParticleVisualizerScreen> | |
with SingleTickerProviderStateMixin { | |
late AnimationController _controller; | |
List<Particle> _particles = []; | |
final Random _random = Random(); | |
Size _screenSize = Size.zero; | |
Color _baseColor = Colors.cyan; | |
final List<Color> _colorPalette = [ | |
Colors.cyan, | |
Colors.pinkAccent, | |
Colors.lightGreenAccent, | |
Colors.orangeAccent, | |
Colors.purpleAccent, | |
Colors.tealAccent, | |
Colors.redAccent, | |
]; | |
int _currentColorIndex = 0; | |
Offset? _interactionPointXY; | |
double _interactionPointZ = 0; | |
bool _repelMode = true; | |
bool _attractorActive = true; | |
double focalLength = 300; | |
double focalDepth = 150; | |
double _animationTime = 0.0; | |
// NEW: State variables for sliders | |
double _particleCount = 150.0; | |
double _focalPointSizeFactor = 1.0; | |
double _numberOfStaticRings = 3.0; | |
@override | |
void initState() { | |
super.initState(); | |
_controller = | |
AnimationController( | |
vsync: this, | |
duration: const Duration(milliseconds: 16), | |
) | |
..addListener(() { | |
_animationTime += 0.05; | |
if (_animationTime > 1000 * pi) _animationTime = 0; | |
_updateParticles(); | |
if (mounted) setState(() {}); | |
}) | |
..repeat(); | |
} | |
@override | |
void didChangeDependencies() { | |
super.didChangeDependencies(); | |
final newScreenSize = MediaQuery.of(context).size; | |
if (_screenSize != newScreenSize && newScreenSize != Size.zero) { | |
_screenSize = newScreenSize; | |
focalLength = _screenSize.width / 1.5; | |
focalDepth = _screenSize.width / 3; | |
if (_attractorActive) { | |
_interactionPointXY = _screenSize.center(Offset.zero); | |
_interactionPointZ = focalDepth / 2; | |
} | |
if (_particles.isEmpty) { | |
_initializeParticles(_particleCount.toInt()); | |
} | |
} | |
} | |
void _initializeParticles(int count) { | |
if (_screenSize == Size.zero || !mounted) return; | |
_particles.clear(); | |
for (int i = 0; i < count; i++) { | |
_particles.add( | |
_createParticle(Offset.zero, 0, burst: false, initialSpread: true), | |
); | |
} | |
} | |
Particle _createParticle( | |
Offset originXY, | |
double originZ, { | |
bool burst = false, | |
bool initialSpread = false, | |
}) { | |
if (_screenSize == Size.zero && !mounted) { | |
return Particle( | |
positionXY: Offset.zero, | |
positionZ: 0, | |
velocityXY: Offset.zero, | |
velocityZ: 0, | |
color: _baseColor, | |
radius: 2, | |
lifespan: 1.0, | |
); | |
} | |
final double angleXY = _random.nextDouble() * 2 * pi; | |
final double angleZ = _random.nextDouble() * 2 * pi; | |
double speedFactor = burst ? 6.0 : 2.5; | |
if (initialSpread) speedFactor = 0; | |
final double vz = | |
cos(angleZ) * | |
(_random.nextDouble() * speedFactor + (initialSpread ? 0.05 : 0.2)); | |
final double xySpeed = | |
sin(angleZ) * | |
(_random.nextDouble() * speedFactor + (initialSpread ? 0.05 : 0.5)); | |
final Offset vxy = Offset(cos(angleXY) * xySpeed, sin(angleXY) * xySpeed); | |
final double radius = _random.nextDouble() * 2.0 + 1.0; | |
final double lifespan = 1.0; | |
final HSLColor hslBase = HSLColor.fromColor(_baseColor); | |
final double hueShift = (_random.nextDouble() - 0.5) * 45; | |
final Color particleColor = | |
hslBase.withHue((hslBase.hue + hueShift) % 360.0).toColor(); | |
Offset pxy = originXY; | |
double pz = originZ; | |
if (initialSpread) { | |
double r = _random.nextDouble() * focalLength * 0.8; | |
double phi = _random.nextDouble() * 2 * pi; | |
double theta = acos(2 * _random.nextDouble() - 1); | |
pxy = Offset(r * sin(theta) * cos(phi), r * sin(theta) * sin(phi)); | |
pz = r * cos(theta); | |
} | |
return Particle( | |
positionXY: pxy, | |
positionZ: pz, | |
velocityXY: vxy, | |
velocityZ: vz, | |
color: particleColor, | |
radius: radius, | |
lifespan: lifespan, | |
); | |
} | |
void _updateParticles() { | |
if (!mounted || _screenSize == Size.zero) return; | |
List<Particle> nextGeneration = []; | |
for (var p in _particles) { | |
Offset? attractorWorldXY; | |
if (_attractorActive && _interactionPointXY != null) { | |
attractorWorldXY = | |
_interactionPointXY! - _screenSize.center(Offset.zero); | |
} | |
p.update( | |
_screenSize, | |
attractorWorldXY, | |
_interactionPointZ, | |
_repelMode, | |
focalDepth, | |
); | |
nextGeneration.add(p); | |
} | |
_particles = nextGeneration; | |
_particles.sort((a, b) => b.positionZ.compareTo(a.positionZ)); | |
// Only add new particles if attractor is off and count is below target | |
// And current particle count is less than desired from slider | |
if (!_attractorActive && | |
_particles.length < _particleCount.toInt() && | |
_random.nextDouble() < 0.1) { | |
if (_particles.length < _particleCount.toInt() * 0.8) { | |
// Add more aggressively if far below target | |
for (int i = 0; i < 5; i++) { | |
_particles.add( | |
_createParticle( | |
Offset.zero, | |
_random.nextDouble() * focalDepth - focalDepth / 2, | |
burst: false, | |
initialSpread: true, | |
), | |
); | |
} | |
} else { | |
_particles.add( | |
_createParticle( | |
Offset.zero, | |
_random.nextDouble() * focalDepth - focalDepth / 2, | |
burst: false, | |
initialSpread: true, | |
), | |
); | |
} | |
} | |
} | |
void _handleTap(Offset tapPosition) { | |
if (!mounted) return; | |
_interactionPointXY = tapPosition; | |
Offset worldTapXY = tapPosition - _screenSize.center(Offset.zero); | |
if (!_attractorActive) { | |
for (int i = 0; i < 40; i++) { | |
_particles.add( | |
_createParticle(worldTapXY, _interactionPointZ, burst: true), | |
); | |
} | |
} | |
if (mounted) setState(() {}); | |
} | |
void _handlePan(Offset panPosition) { | |
if (!mounted) return; | |
_interactionPointXY = panPosition; | |
Offset worldPanXY = panPosition - _screenSize.center(Offset.zero); | |
if (!_attractorActive) { | |
if (_random.nextDouble() < 0.4) { | |
_particles.add( | |
_createParticle(worldPanXY, _interactionPointZ, burst: false), | |
); | |
} | |
} | |
if (mounted) setState(() {}); | |
} | |
void _changeBaseColor() { | |
if (!mounted) return; | |
setState(() { | |
_currentColorIndex = (_currentColorIndex + 1) % _colorPalette.length; | |
_baseColor = _colorPalette[_currentColorIndex]; | |
}); | |
} | |
void _toggleAttractorRepulsor() { | |
if (!mounted) return; | |
setState(() { | |
if (!_attractorActive) { | |
_attractorActive = true; | |
_repelMode = true; | |
_interactionPointXY ??= _screenSize.center(Offset.zero); | |
_interactionPointZ = focalDepth / 2; | |
} else if (_repelMode) { | |
_repelMode = false; | |
} else { | |
_attractorActive = false; | |
} | |
}); | |
} | |
String get _attractorButtonText { | |
if (!_attractorActive) return "Activate Repel"; | |
if (_repelMode) return "Switch to Attract"; | |
return "Deactivate"; | |
} | |
IconData get _attractorButtonIcon { | |
if (!_attractorActive) return Icons.arrow_circle_up_outlined; | |
if (_repelMode) return Icons.arrow_circle_down_outlined; | |
return Icons.power_settings_new_outlined; | |
} | |
void _resetParticles() { | |
if (!mounted) return; | |
setState(() { | |
_initializeParticles(_particleCount.toInt()); | |
if (_attractorActive) { | |
_interactionPointXY = _screenSize.center(Offset.zero); | |
_interactionPointZ = focalDepth / 2; | |
} | |
}); | |
} | |
@override | |
void dispose() { | |
_controller.dispose(); | |
super.dispose(); | |
} | |
Widget _buildSlider({ | |
required String label, | |
required double value, | |
required double min, | |
required double max, | |
required int divisions, | |
required ValueChanged<double> onChanged, | |
}) { | |
return Column( | |
mainAxisSize: MainAxisSize.min, | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Padding( | |
padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 4.0), | |
child: Text( | |
'$label: ${value.toStringAsFixed(label == "Focal Size" ? 1 : 0)}', | |
style: TextStyle(color: Colors.white, fontSize: 12), | |
), | |
), | |
Slider( | |
value: value, | |
min: min, | |
max: max, | |
divisions: divisions, | |
label: value.toStringAsFixed(label == "Focal Size" ? 1 : 0), | |
onChanged: onChanged, | |
), | |
], | |
); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar( | |
title: const Text('3D Particle Cloud'), | |
elevation: 0, | |
backgroundColor: Colors.black.withOpacity(0.3), | |
), | |
backgroundColor: const Color(0xFF000010), | |
body: LayoutBuilder( | |
builder: (context, constraints) { | |
final newSize = Size(constraints.maxWidth, constraints.maxHeight); | |
if (_screenSize != newSize && newSize != Size.zero) { | |
_screenSize = newSize; | |
focalLength = _screenSize.width / 1.5; | |
focalDepth = _screenSize.width / 3; | |
if (_attractorActive || _particles.isEmpty) { | |
_interactionPointXY = _screenSize.center(Offset.zero); | |
_interactionPointZ = focalDepth / 2; | |
} | |
if (_particles.isEmpty) | |
_initializeParticles(_particleCount.toInt()); | |
} | |
return GestureDetector( | |
onTapDown: (details) => _handleTap(details.localPosition), | |
onPanUpdate: (details) => _handlePan(details.localPosition), | |
child: CustomPaint( | |
painter: ParticlePainter( | |
particles: _particles, | |
screenSize: _screenSize, | |
focalLength: focalLength, | |
interactionPointXY: _interactionPointXY, | |
interactionPointZ: _interactionPointZ, | |
attractorActive: _attractorActive, | |
repelMode: _repelMode, | |
animationTime: _animationTime, | |
focalPointSizeFactor: _focalPointSizeFactor, // NEW | |
numberOfStaticRings: _numberOfStaticRings.toInt(), // NEW | |
), | |
child: Container(), | |
), | |
); | |
}, | |
), | |
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, | |
floatingActionButton: Padding( | |
padding: const EdgeInsets.all(8.0), // Reduced padding | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: [ | |
Wrap( | |
alignment: WrapAlignment.center, | |
spacing: 8.0, | |
runSpacing: 8.0, | |
children: <Widget>[ | |
FloatingActionButton.extended( | |
onPressed: _changeBaseColor, | |
tooltip: 'Change Color', | |
icon: const Icon(Icons.color_lens_outlined), | |
label: Text( | |
_colorPalette[(_currentColorIndex + 1) % | |
_colorPalette.length] | |
.value | |
.toRadixString(16) | |
.padLeft(8, '0') | |
.substring(2) | |
.toUpperCase(), | |
), | |
heroTag: "fab_color_3d", | |
backgroundColor: _baseColor, | |
foregroundColor: | |
_baseColor.computeLuminance() > 0.5 | |
? Colors.black | |
: Colors.white, | |
), | |
FloatingActionButton.extended( | |
onPressed: _toggleAttractorRepulsor, | |
tooltip: _attractorButtonText, | |
icon: Icon(_attractorButtonIcon), | |
label: Text(_attractorButtonText), | |
heroTag: "fab_attractor_3d", | |
), | |
FloatingActionButton( | |
onPressed: _resetParticles, | |
tooltip: 'Reset Particles', | |
child: const Icon(Icons.refresh), | |
heroTag: "fab_reset_3d", | |
), | |
], | |
), | |
SizedBox(height: 10), | |
Container( | |
padding: EdgeInsets.symmetric(vertical: 0), // Reduced padding | |
decoration: BoxDecoration( | |
color: Colors.black.withOpacity(0.6), | |
borderRadius: BorderRadius.circular(10), | |
), | |
child: Row( | |
children: [ | |
_buildSlider( | |
label: 'Particles', | |
value: _particleCount, | |
min: 20, | |
max: 2000, | |
divisions: (2000 - 20) ~/ 10, // e.g. 48 divisions | |
onChanged: (double value) { | |
setState(() { | |
_particleCount = value; | |
_initializeParticles(_particleCount.toInt()); | |
}); | |
}, | |
), | |
_buildSlider( | |
label: 'Focal Size', | |
value: _focalPointSizeFactor, | |
min: 0.5, | |
max: 2.5, | |
divisions: ((2.5 - 0.5) / 0.1).round(), // 20 divisions | |
onChanged: (double value) { | |
setState(() { | |
_focalPointSizeFactor = value; | |
}); | |
}, | |
), | |
_buildSlider( | |
label: 'Rings', | |
value: _numberOfStaticRings, | |
min: 0, | |
max: 6, | |
divisions: 6, | |
onChanged: (double value) { | |
setState(() { | |
_numberOfStaticRings = value; | |
}); | |
}, | |
), | |
], | |
), | |
), | |
], | |
), | |
), | |
); | |
} | |
} | |
class ParticlePainter extends CustomPainter { | |
final List<Particle> particles; | |
final Size screenSize; | |
final double focalLength; | |
final Offset? interactionPointXY; | |
final double interactionPointZ; | |
final bool attractorActive; | |
final bool repelMode; | |
final double animationTime; | |
final double focalPointSizeFactor; // NEW | |
final int numberOfStaticRings; // NEW | |
ParticlePainter({ | |
required this.particles, | |
required this.screenSize, | |
required this.focalLength, | |
this.interactionPointXY, | |
required this.interactionPointZ, | |
required this.attractorActive, | |
required this.repelMode, | |
required this.animationTime, | |
required this.focalPointSizeFactor, // NEW | |
required this.numberOfStaticRings, // NEW | |
}); | |
@override | |
void paint(Canvas canvas, Size size) { | |
if (screenSize == Size.zero || focalLength <= 0) return; | |
final paint = Paint()..style = PaintingStyle.fill; | |
final Offset screenCenter = size.center(Offset.zero); | |
for (var p in particles) { | |
double pScale = focalLength / (focalLength + p.positionZ + 0.001); | |
double projectedX = p.positionXY.dx * pScale + screenCenter.dx; | |
double projectedY = p.positionXY.dy * pScale + screenCenter.dy; | |
double projectedRadius = p.radius * pScale; | |
if (projectedRadius < 0.2 || pScale < 0) continue; | |
paint.color = p.color.withOpacity( | |
p.color.opacity * pScale.clamp(0.1, 1.0), | |
); | |
canvas.drawCircle( | |
Offset(projectedX, projectedY), | |
max(0.5, projectedRadius), | |
paint, | |
); | |
} | |
if (attractorActive && interactionPointXY != null) { | |
Offset attractorWorldXY = interactionPointXY! - screenCenter; | |
double focalPointScale = | |
focalLength / | |
(focalLength + | |
interactionPointZ + | |
0.001); // Renamed to avoid confusion with pScale | |
double projectedX = | |
attractorWorldXY.dx * focalPointScale + screenCenter.dx; | |
double projectedY = | |
attractorWorldXY.dy * focalPointScale + screenCenter.dy; | |
if (focalPointScale > 0) { | |
final baseAttractorColor = | |
repelMode ? Colors.redAccent : Colors.greenAccent; | |
// Core | |
final double coreBaseSize = 8.0; | |
final double coreSize = max( | |
1.0, | |
(coreBaseSize * focalPointSizeFactor) * focalPointScale, | |
); | |
final attractorCorePaint = | |
Paint() | |
..color = baseAttractorColor.withOpacity( | |
focalPointScale.clamp(0.7, 1.0), | |
) | |
..style = PaintingStyle.fill; | |
canvas.drawCircle( | |
Offset(projectedX, projectedY), | |
coreSize, | |
attractorCorePaint, | |
); | |
// Rings | |
double ringBaseSize = 12.0 * focalPointSizeFactor * focalPointScale; | |
double ringSpacing = 8.0 * focalPointSizeFactor * focalPointScale; | |
double ringStrokeBase = 1.5 * focalPointSizeFactor * focalPointScale; | |
for (int i = 0; i < numberOfStaticRings; i++) { | |
double currentRingSize = ringBaseSize + (i * ringSpacing); | |
double currentStrokeWidth = max( | |
0.5, | |
ringStrokeBase * (1 - i / (numberOfStaticRings + 1) * 0.7), | |
); // Avoid division by zero if numberOfStaticRings is 0 | |
double currentOpacity = | |
0.4 * | |
(1 - i / (numberOfStaticRings + 2)) * | |
focalPointScale.clamp(0.2, 1.0); | |
final ringPaint = | |
Paint() | |
..color = baseAttractorColor.withOpacity(currentOpacity) | |
..style = PaintingStyle.stroke | |
..strokeWidth = currentStrokeWidth; | |
if (currentRingSize > 0 && currentStrokeWidth > 0) { | |
// Ensure drawable | |
canvas.drawCircle( | |
Offset(projectedX, projectedY), | |
currentRingSize, | |
ringPaint, | |
); | |
} | |
} | |
// Wavy Animated Glow - Always visible and waving | |
double baseGlowRadiusValue = | |
(numberOfStaticRings > 0 | |
? (ringBaseSize + (numberOfStaticRings * ringSpacing)) | |
: ringBaseSize * 0.8) + | |
(ringSpacing * 0.5); | |
if (numberOfStaticRings == 0) | |
baseGlowRadiusValue = | |
coreBaseSize * 1.5 * focalPointSizeFactor * focalPointScale; | |
double waveAmplitudeBase = 4.0 * focalPointSizeFactor; | |
double waveAmplitudeModulation = 2.0 * focalPointSizeFactor; | |
double waveAmplitude = | |
(waveAmplitudeBase + | |
waveAmplitudeModulation * sin(animationTime * 0.7)) * | |
focalPointScale; | |
int numWaves = | |
5 + (sin(animationTime * 0.3) * 2).toInt(); // 3 to 7 waves | |
double waveSpeed = 1.2; | |
int numSegments = 72; | |
final glowPath = Path(); | |
for (int i = 0; i <= numSegments; i++) { | |
double angle = (i / numSegments) * 2 * pi; | |
double radius = | |
baseGlowRadiusValue + | |
waveAmplitude * sin(numWaves * angle + waveSpeed * animationTime); | |
if (radius < 0) radius = 0; // Prevent negative radius | |
double pointX = projectedX + radius * cos(angle); | |
double pointY = projectedY + radius * sin(angle); | |
if (i == 0) { | |
glowPath.moveTo(pointX, pointY); | |
} else { | |
glowPath.lineTo(pointX, pointY); | |
} | |
} | |
glowPath.close(); | |
double glowOpacityBase = 0.12; | |
double glowOpacityModulation = 0.08; | |
double glowFinalOpacity = | |
(glowOpacityBase + | |
glowOpacityModulation * sin(animationTime * 1.5)) * | |
focalPointScale.clamp(0.1, 1.0); | |
double glowStrokeBase = 2.5 * focalPointSizeFactor; | |
double glowStrokeModulation = 1.5 * focalPointSizeFactor; | |
double glowFinalStrokeWidth = max( | |
0.5, | |
(glowStrokeBase + glowStrokeModulation * sin(animationTime)) * | |
focalPointScale, | |
); | |
final glowPaint = | |
Paint() | |
..color = baseAttractorColor.withOpacity( | |
glowFinalOpacity.clamp(0.0, 1.0), | |
) | |
..style = PaintingStyle.stroke | |
..strokeWidth = glowFinalStrokeWidth; | |
if (baseGlowRadiusValue > 0 || waveAmplitude > 0) { | |
// Ensure path is meaningful | |
canvas.drawPath(glowPath, glowPaint); | |
} | |
} | |
} | |
} | |
@override | |
bool shouldRepaint(covariant ParticlePainter oldDelegate) { | |
return true; // Always repaint for animation and state changes via sliders | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment