Last active
April 9, 2024 12:01
-
-
Save PlugFox/9d251f3300804746884e2c2f8824babb to your computer and use it in GitHub Desktop.
Sunflower
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
/* | |
* Sunflower | |
* https://gist.github.com/PlugFox/9d251f3300804746884e2c2f8824babb | |
* https://dartpad.dev?id=9d251f3300804746884e2c2f8824babb | |
* Mike Matiunin <plugfox@gmail.com>, 06 March 2024 | |
*/ | |
import 'dart:async'; | |
import 'dart:math' as math; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/scheduler.dart'; | |
void main() => runZonedGuarded<void>( | |
() { | |
const max = 250; | |
final controller = ValueNotifier<int>(max ~/ 2); | |
runApp( | |
MaterialApp( | |
title: 'Material App', | |
theme: ThemeData.dark(), | |
debugShowCheckedModeBanner: false, | |
home: Scaffold( | |
body: SafeArea( | |
child: Padding( | |
padding: const EdgeInsets.all(16), | |
child: Sunflower( | |
max: max, | |
seeds: controller, | |
), | |
), | |
), | |
), | |
), | |
); | |
}, | |
(error, stackTrace) => print(error), | |
); | |
class SunflowerController with ChangeNotifier { | |
SunflowerController({required int max, Curve curve = Curves.easeInOut}) | |
: _curve = curve, | |
_state = const <double>[], | |
_progress = const <double>[], | |
_speed = const <double>[], | |
_max = max { | |
changeMax(max); | |
} | |
final Curve _curve; | |
List<double> _state; | |
List<double> _progress; | |
List<double> _speed; | |
List<double> get progress => _progress; | |
int _max; | |
int get max => _max; | |
// Set the maximum number of seeds | |
void changeMax(int max) { | |
final oldValue = _state; | |
final rnd = math.Random(); | |
_max = max; | |
_state = List<double>.generate( | |
max, (i) => i >= oldValue.length ? 0 : oldValue[i], | |
growable: false); | |
_progress = List<double>.unmodifiable(_state.map<double>(_curve.transform)); | |
_speed = List<double>.generate(max, (_) => (rnd.nextInt(50) + 100) / 100, | |
growable: false); | |
} | |
// Update progress based on the new seed and elapsed time | |
// Returns true if the progress was updated | |
bool update(double delta, int seed) { | |
var updated = false; | |
for (var i = 0; i < seed; i++) { | |
if (_state[i] == 1) continue; | |
_state[i] = math.min(1, _state[i] + delta * _speed[i]); | |
updated = true; | |
} | |
for (var i = seed; i < _max; i++) { | |
if (_state[i] == 0) continue; | |
_state[i] = math.max(0, _state[i] - delta * _speed[i]); | |
updated = true; | |
} | |
if (updated) { | |
_progress = | |
List<double>.unmodifiable(_state.map<double>(_curve.transform)); | |
notifyListeners(); | |
} | |
return updated; | |
} | |
} | |
class Sunflower extends StatefulWidget { | |
Sunflower({ | |
required this.seeds, | |
this.max = 250, | |
this.duration = const Duration(milliseconds: 2000), | |
super.key, // ignore: unused_element | |
}) : assert(seeds.value <= max, 'Seeds must be less than or equal to max'); | |
/// The maximum number of seeds to show in the sunflower. | |
final int max; | |
/// Seeds to show in the sunflower. | |
final ValueNotifier<int> seeds; | |
/// The duration of the animation. | |
final Duration duration; | |
@override | |
State<Sunflower> createState() => _SunflowerState(); | |
} | |
class _SunflowerState extends State<Sunflower> | |
with SingleTickerProviderStateMixin { | |
late final SunflowerController _controller; | |
late final Ticker _ticker; | |
@override | |
void initState() { | |
super.initState(); | |
_controller = SunflowerController(max: widget.max); | |
widget.seeds.addListener(_onSeedsChanged); | |
_ticker = createTicker(_onTick()); | |
_onSeedsChanged(); | |
} | |
@override | |
void didUpdateWidget(covariant Sunflower oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
var changed = false; | |
if (oldWidget.max != widget.max) { | |
_controller.changeMax(widget.max); | |
changed = true; | |
} | |
if (!identical(oldWidget.seeds, widget.seeds)) { | |
oldWidget.seeds.removeListener(_onSeedsChanged); | |
widget.seeds.addListener(_onSeedsChanged); | |
changed = true; | |
} | |
if (changed) _onSeedsChanged(); | |
} | |
@override | |
void dispose() { | |
widget.seeds.removeListener(_onSeedsChanged); | |
_controller.dispose(); | |
_ticker.dispose(); | |
super.dispose(); | |
} | |
void _onSeedsChanged() { | |
if (_ticker.isActive) return; | |
_ticker.start(); | |
} | |
void Function(Duration elapsed) _onTick() { | |
var prev = Duration.zero; | |
return (elapsed) { | |
final delta = ((elapsed.inMilliseconds - prev.inMilliseconds) / | |
widget.duration.inMilliseconds) | |
.clamp(0.0, 1.0); | |
final updated = _controller.update(delta, widget.seeds.value); | |
if (updated) { | |
prev = elapsed; | |
} else { | |
_ticker.stop(); | |
prev = Duration.zero; | |
} | |
}; | |
} | |
@override | |
Widget build(BuildContext context) => Center( | |
child: Stack( | |
fit: StackFit.expand, | |
alignment: Alignment.center, | |
children: <Widget>[ | |
Positioned( | |
left: 0, | |
right: 0, | |
top: 16, | |
bottom: 72 + 16, | |
child: Center( | |
child: AspectRatio( | |
aspectRatio: 1, | |
child: RepaintBoundary( | |
child: CustomPaint( | |
painter: SunflowerPainter( | |
controller: _controller, | |
), | |
), | |
), | |
), | |
), | |
), | |
Positioned( | |
left: 0, | |
right: 0, | |
height: 72, | |
bottom: 16, | |
child: Center( | |
child: SizedBox( | |
width: 300, | |
child: ValueListenableBuilder<int>( | |
valueListenable: widget.seeds, | |
builder: (context, value, child) => Column( | |
mainAxisSize: MainAxisSize.min, | |
mainAxisAlignment: MainAxisAlignment.start, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: <Widget>[ | |
Slider( | |
min: 1, | |
max: widget.max.toDouble(), | |
value: value.toDouble(), | |
onChanged: (val) => widget.seeds.value = val.toInt(), | |
), | |
Text( | |
'${value * 100 ~/ widget.max}%', | |
maxLines: 1, | |
overflow: TextOverflow.ellipsis, | |
textAlign: TextAlign.center, | |
), | |
], | |
), | |
), | |
), | |
), | |
), | |
], | |
), | |
); | |
} | |
/// {@template sunflower} | |
/// SunflowerPainter. | |
/// {@endtemplate} | |
class SunflowerPainter extends CustomPainter { | |
/// {@macro sunflower} | |
const SunflowerPainter( | |
{required SunflowerController controller, bool skipFirst = true}) | |
: _controller = controller, | |
_skipFirst = skipFirst, | |
super(repaint: controller); | |
/// The controller for the sunflower | |
final SunflowerController _controller; | |
/// Whether to skip the first seed | |
final bool _skipFirst; | |
/// The outer paint for the circle | |
static final _outerPaint = Paint() | |
..color = Colors.grey.shade700 | |
..style = PaintingStyle.fill; | |
/// The inner paint for the sunflower | |
static final _innerPaint = Paint() | |
..color = Colors.deepOrange | |
..style = PaintingStyle.fill; | |
/// Sunflower padding to the edge of the canvas 0..1 | |
static const padding = 0.15; | |
/// Tau (τ) is the ratio of a circle's circumference to its radius | |
static const tau = math.pi * 2; | |
/// Phi (φ) is the golden ratio (1 + √5) / 2 | |
static final phi = (math.sqrt(5) + 1) / 2; | |
static double _lerpDouble(double a, double b, double t) => | |
a * (1.0 - t) + b * t; | |
static Offset _lerpOffset(Offset a, Offset b, double t) => | |
Offset(_lerpDouble(a.dx, b.dx, t), _lerpDouble(a.dy, b.dy, t)); | |
static Offset _evalOuter(double radius, int max, int i) { | |
final theta = i * tau / (max - 1); | |
final r = radius; | |
return Offset(r * math.cos(theta), r * math.sin(theta)); | |
} | |
static Offset _evalInner(double radius, int max, int i) { | |
final theta = i * tau / phi; | |
final r = math.sqrt(i / (max + 0.5)) * radius * (1 - padding); | |
return Offset(r * math.cos(theta), r * math.sin(theta)); | |
} | |
@override | |
void paint(Canvas canvas, Size size) { | |
if (size.shortestSide < padding * 3) return; | |
final max = _controller.max; | |
final radius = size.shortestSide / 2; | |
final center = Offset(size.width / 2, size.height / 2); | |
final outerDotRadius = size.shortestSide / _controller.max; | |
final innerDotRadius = outerDotRadius * 5; | |
// Draw the seeds | |
for (var i = _skipFirst ? 1 : 0; i < _controller.max; i++) { | |
switch (_controller.progress[i]) { | |
case 0.0: | |
canvas.drawCircle( | |
center + _evalOuter(radius, max, i), | |
outerDotRadius, | |
_outerPaint, | |
); | |
case 1.0: | |
canvas.drawCircle( | |
center + _evalInner(radius, max, i), | |
innerDotRadius, | |
_innerPaint, | |
); | |
case double t: | |
canvas.drawCircle( | |
center + | |
_lerpOffset( | |
_evalOuter(radius, max, i), _evalInner(radius, max, i), t), | |
_lerpDouble(outerDotRadius, innerDotRadius, t), | |
Paint() | |
..color = Color.lerp(_outerPaint.color, _innerPaint.color, t)! | |
..style = PaintingStyle.stroke, | |
); | |
} | |
} | |
} | |
@override | |
bool shouldRepaint(covariant SunflowerPainter oldDelegate) => | |
!identical(oldDelegate._controller, _controller); | |
@override | |
bool shouldRebuildSemantics(covariant SunflowerPainter oldDelegate) => false; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment