Skip to content

Instantly share code, notes, and snippets.

@evanca
Created November 28, 2025 07:13
Show Gist options
  • Select an option

  • Save evanca/068ea4cd8f2dfba46c1657cbc453c2f0 to your computer and use it in GitHub Desktop.

Select an option

Save evanca/068ea4cd8f2dfba46c1657cbc453c2f0 to your computer and use it in GitHub Desktop.
Vibe coded Flutter adaptation of Animated Tree by byteab
// Flutter adaptation of Animated Tree by byteab
// Source: https://github.com/byteab/delightful-react-native-animations/blob/main/packages/app/features/tree/animated-tree.tsx
// Generated by Gemini 3 Pro
// This AI-generated code has not been performance-tested or optimised and should be reviewed before use
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
// --- Constants & Configuration ---
const _idealTree = (
axiom: 'F',
rules: {'F': 'FF+[+F-F-F]-[-F+F+F]'},
iterations: 4,
);
const double _rotateAngle = (20 / 180) * pi;
const double _initialAngle = -pi / 2; // Pointing up in Flutter's coordinate system
const double _branchLength = 8.0;
const double _noiseOffset = 0.1;
const double _angleMin = -pi / 25;
const double _angleMax = pi / 40;
const double _angleRange = _angleMax - _angleMin;
const int _strokeQuantization = 20;
// --- Utils ---
/// Generates the L-System string recursively.
String generateLSystemString(String axiom, Map<String, String> rules, int iterations) {
String current = axiom;
for (int i = 0; i < iterations; i++) {
final buffer = StringBuffer();
for (int j = 0; j < current.length; j++) {
final char = current[j];
buffer.write(rules[char] ?? char);
}
current = buffer.toString();
}
return current;
}
/// Simple 1D noise function (superposition of sine waves).
/// Returns a value roughly between 0 and 1.
double noise(double t) {
return (sin(t) + sin(2.2 * t + 5.52) + sin(2.9 * t + 0.93) + sin(4.6 * t + 8.94)) / 4.0 + 0.5;
}
// --- Data Structures ---
class BranchInfo {
final Offset start;
final Offset end;
final int depth;
final int maxDepth;
BranchInfo(this.start, this.end, this.depth, this.maxDepth);
}
class SavedState {
final double angle;
final Offset position;
final int depth;
SavedState(this.angle, this.position, this.depth);
}
// --- Widget ---
class AnimatedTree extends StatefulWidget {
const AnimatedTree({super.key});
@override
State<AnimatedTree> createState() => _AnimatedTreeState();
}
class _AnimatedTreeState extends State<AnimatedTree> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final String _treeString;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 300), // Very slow loop for wind
)..repeat();
_treeString = generateLSystemString(
_idealTree.axiom,
_idealTree.rules,
_idealTree.iterations,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
// Wind varies with time.
// The original used: ((clock.value / 1000) % 200) * 1.3
// We simulate this with the controller value.
final wind = (_controller.value * 200) * 1.3;
// Leaf animation logic
// Time in seconds (approximate, since loop is 300s)
final t = _controller.value * 300;
// Horizontal drift: Combination of slow wind drift and faster oscillation
// noise(wind) gives the general wind direction influence
// sin(t * 0.5) adds a wandering path
final xOffset = (noise(wind) - 0.5) * 300 + sin(t * 0.5) * 50;
// Vertical float: Bobbing up and down
// cos(t * 0.3) for gentle bobbing
final yOffset = cos(t * 0.3) * 40 + (noise(wind + 50) - 0.5) * 50;
// Rotation: Leaves flutter (rock back and forth) faster than they drift
// sin(t * 2.5) for the flutter
// noise adds some randomness to the tilt
final rotation = sin(t * 2.5) * 0.3 + (noise(wind + 20) - 0.5) * 0.5;
return Stack(
children: [
Positioned.fill(
child: CustomPaint(
painter: TreePainter(
treeString: _treeString,
wind: wind,
maxDepth: _idealTree.iterations,
),
size: Size.infinite,
),
),
Positioned(
top: 150, // Lower base position to give room for floating up
right: 150,
child: Transform.translate(
offset: Offset(xOffset, yOffset),
child: Transform.rotate(
angle: rotation,
child: const FlutterLogo(size: 60),
),
),
),
],
);
},
);
}
}
// --- Painter ---
class TreePainter extends CustomPainter {
final String treeString;
final double wind;
final int maxDepth;
TreePainter({
required this.treeString,
required this.wind,
required this.maxDepth,
});
@override
void paint(Canvas canvas, Size size) {
// Center the tree at the bottom
final origin = Offset(size.width / 2, size.height - 50);
_drawTree(canvas, origin);
}
void _drawTree(Canvas canvas, Offset startOrigin) {
final savedStates = <SavedState>[];
double angle = _initialAngle;
Offset currentPos = startOrigin;
int depth = 0;
int branchIndex = 0;
// Pre-calculate paints for different stroke widths to avoid recreating them
final paints = List.generate(_strokeQuantization + 1, (index) {
final t = index / _strokeQuantization;
final decayFactor = pow(1 - t, 2.0);
final strokeWidth = 1.2 + (4.0 - 1.2) * decayFactor;
final easedDepth = t * 0.7 + pow(t, 2) * 0.3;
// Flutter Brand Colors interpolation
// Base (Dark Blue): Color(0xFF01579B) -> R:1, G:87, B:155
// Tips (Light Blue): Color(0xFF4FC3F7) -> R:79, G:195, B:247
final r = (1 + (79 - 1) * easedDepth).round();
final g = (87 + (195 - 87) * easedDepth).round();
final b = (155 + (247 - 155) * easedDepth).round();
return Paint()
..color = Color.fromARGB(255, r, g, b)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
});
// We can batch paths by stroke width index if we want to optimize draw calls,
// but for immediate mode canvas in Flutter, drawing line by line is often fine
// unless count is huge. The original tree has 4 iterations, which isn't massive.
// However, the original code groups by stroke width. Let's do that for quality.
final paths = List.generate(_strokeQuantization + 1, (_) => Path());
for (int i = 0; i < treeString.length; i++) {
final char = treeString[i];
if (char == '+') {
angle -= _rotateAngle;
} else if (char == '-') {
angle += _rotateAngle;
} else if (char == '[') {
savedStates.add(SavedState(angle, currentPos, depth));
depth++;
} else if (char == ']') {
if (savedStates.isNotEmpty) {
final state = savedStates.removeLast();
angle = state.angle;
currentPos = state.position;
depth = state.depth;
}
} else if (char == 'F') {
final noiseValue = noise(wind + branchIndex * _noiseOffset);
final clampedNoise = noiseValue.clamp(0.0, 1.0);
final newAngle = _angleMin + clampedNoise * _angleRange;
angle += newAngle;
branchIndex++;
final normalizedDepth = depth / maxDepth;
final easedDepth = 1 - pow(1 - normalizedDepth, 3);
final currentLength = _branchLength * (1 - easedDepth * 0.15);
final endPos = Offset(
currentPos.dx + currentLength * cos(angle),
currentPos.dy + currentLength * sin(angle),
);
final strokeWidthIndex = (normalizedDepth * _strokeQuantization).round().clamp(0, _strokeQuantization);
paths[strokeWidthIndex].moveTo(currentPos.dx, currentPos.dy);
paths[strokeWidthIndex].lineTo(endPos.dx, endPos.dy);
currentPos = endPos;
}
}
// Draw all paths
for (int i = 0; i <= _strokeQuantization; i++) {
if (paths[i].getBounds().isEmpty) continue; // Optimization: skip empty paths
canvas.drawPath(paths[i], paints[i]);
}
}
@override
bool shouldRepaint(covariant TreePainter oldDelegate) {
return oldDelegate.wind != wind || oldDelegate.treeString != treeString;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment