Created
November 28, 2025 07:13
-
-
Save evanca/068ea4cd8f2dfba46c1657cbc453c2f0 to your computer and use it in GitHub Desktop.
Vibe coded Flutter adaptation of Animated Tree by byteab
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
| // 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