-
-
Save slightfoot/5943f5a40b09f90f1ecce529832394ec to your computer and use it in GitHub Desktop.
Thanos Disintegrate Animation - by Phil & Simon Lightfoot :: #HumpdayQandA on 5th November 2025 :: https://www.youtube.com/watch?v=uNb9L5JbfIc
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
| // MIT License | |
| // | |
| // Copyright (c) 2025 Phil(@callmephil) & Simon Lightfoot | |
| // | |
| // Permission is hereby granted, free of charge, to any person obtaining a copy | |
| // of this software and associated documentation files (the "Software"), to deal | |
| // in the Software without restriction, including without limitation the rights | |
| // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| // copies of the Software, and to permit persons to whom the Software is | |
| // furnished to do so, subject to the following conditions: | |
| // | |
| // The above copyright notice and this permission notice shall be included in all | |
| // copies or substantial portions of the Software. | |
| // | |
| // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| // SOFTWARE. | |
| // | |
| import 'dart:math'; | |
| import 'dart:ui' as ui; | |
| import 'package:flutter/material.dart'; | |
| // The effect is like: https://elgoog.im/thanos/ | |
| void main() => runApp(const MaterialApp(home: ThanosSnapDemo())); | |
| class ThanosSnapDemo extends StatefulWidget { | |
| const ThanosSnapDemo({super.key}); | |
| @override | |
| State<ThanosSnapDemo> createState() => _ThanosSnapDemoState(); | |
| } | |
| class _ThanosSnapDemoState extends State<ThanosSnapDemo> { | |
| bool snapped = false; | |
| void _toggleSnap() { | |
| setState(() => snapped = !snapped); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| backgroundColor: Colors.black, | |
| body: Center( | |
| child: Padding( | |
| padding: const EdgeInsets.all(8), | |
| child: Column( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: [ | |
| const SizedBox(height: 20), | |
| ElevatedButton( | |
| onPressed: _toggleSnap, | |
| child: const Text('Snap'), | |
| ), | |
| const SizedBox(height: 20), | |
| Expanded( | |
| child: AnimatedThanosSnap( | |
| text: | |
| 'Hmm. We\'re having trouble finding that site. ' | |
| 'Please check the URL or try again later. ' | |
| 'If the problem persists, contact support.', | |
| snapped: snapped, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class AnimatedThanosSnap extends StatefulWidget { | |
| const AnimatedThanosSnap({ | |
| super.key, | |
| required this.text, | |
| required this.snapped, | |
| }); | |
| final String text; | |
| final bool snapped; | |
| @override | |
| State<AnimatedThanosSnap> createState() => _AnimatedThanosSnapState(); | |
| } | |
| class _AnimatedThanosSnapState extends State<AnimatedThanosSnap> | |
| with SingleTickerProviderStateMixin { | |
| late AnimationController _controller; | |
| late List<CharacterAnimation> _charAnimations; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _controller = AnimationController( | |
| duration: const Duration(milliseconds: 2000), | |
| vsync: this, | |
| ); | |
| _initializeAnimations(); | |
| if(!widget.snapped) { | |
| _controller.forward(from: 0); | |
| } | |
| } | |
| @override | |
| void dispose() { | |
| _controller.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| void didUpdateWidget(AnimatedThanosSnap oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (oldWidget.text != widget.text) { | |
| _initializeAnimations(); | |
| } | |
| if (oldWidget.snapped != widget.snapped) { | |
| _controller.forward(from: 0); | |
| } | |
| } | |
| void _initializeAnimations() { | |
| final chars = widget.text.split(''); | |
| final random = Random(42); | |
| final groups = List.generate( | |
| chars.length, | |
| (i) => random.nextInt(6), | |
| ); | |
| _charAnimations = List.generate(chars.length, (i) { | |
| final charRandom = Random(i); | |
| return CharacterAnimation( | |
| char: chars[i], | |
| dx: (charRandom.nextDouble() - 0.5) * 200, | |
| dy: charRandom.nextDouble() * 300 * (charRandom.nextBool() ? 1 : -1), | |
| blurRadius: charRandom.nextDouble() * 4 + 2, | |
| group: groups[i], | |
| ); | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return CustomPaint( | |
| painter: AnimatedTextCustomPainter( | |
| animation: _controller, | |
| text: widget.text, | |
| charAnimations: _charAnimations, | |
| snapped: widget.snapped, | |
| ), | |
| size: Size.infinite, | |
| ); | |
| } | |
| } | |
| class CharacterAnimation { | |
| CharacterAnimation({ | |
| required this.char, | |
| required this.dx, | |
| required this.dy, | |
| required this.blurRadius, | |
| required this.group, | |
| }); | |
| final String char; | |
| final double dx; | |
| final double dy; | |
| final double blurRadius; | |
| final int group; | |
| } | |
| class AnimatedTextCustomPainter extends CustomPainter { | |
| AnimatedTextCustomPainter({ | |
| required this.animation, | |
| required this.text, | |
| required this.charAnimations, | |
| required this.snapped, | |
| }) : super(repaint: animation); | |
| final Animation<double> animation; | |
| final String text; | |
| final List<CharacterAnimation> charAnimations; | |
| final bool snapped; | |
| @override | |
| void paint(Canvas canvas, Size size) { | |
| const textStyle = TextStyle( | |
| fontSize: 36.0, | |
| color: Colors.white, | |
| fontWeight: FontWeight.bold, | |
| ); | |
| // First, layout the text to get natural character positions | |
| final textPainter = TextPainter( | |
| text: TextSpan(text: text, style: textStyle), | |
| textDirection: TextDirection.ltr, | |
| textAlign: TextAlign.center, | |
| ); | |
| textPainter.layout(maxWidth: size.width); | |
| // Get the starting position to center the text | |
| final startX = (size.width - textPainter.width) / 2; | |
| final startY = (size.height - textPainter.height) / 2; | |
| // Draw each character individually with its animation | |
| for (int i = 0; i < charAnimations.length; i++) { | |
| final charAnim = charAnimations[i]; | |
| // Calculate the natural position of this character | |
| final charOffset = textPainter.getOffsetForCaret( | |
| TextPosition(offset: i), | |
| Rect.zero, | |
| ); | |
| // Calculate animation progress for this character's group | |
| const groupDelay = 0.06; // 120ms / 2000ms | |
| const moveDuration = 0.4; // 800ms / 2000ms | |
| const fadeDuration = 0.6; // 1200ms / 2000ms | |
| const blurDuration = 0.2; // 400ms / 2000ms | |
| const scaleDuration = 0.4; // 800ms / 2000ms | |
| final groupStart = charAnim.group * groupDelay; | |
| // Calculate individual animation values | |
| final animationValue = animation.value; | |
| final moveProgress = ((animationValue - groupStart) / moveDuration).clamp(0.0, 1.0); | |
| final fadeProgress = ((animationValue - groupStart) / fadeDuration).clamp(0.0, 1.0); | |
| final blurProgress = ((animationValue - groupStart) / blurDuration).clamp(0.0, 1.0); | |
| final scaleProgress = ((animationValue - groupStart) / scaleDuration).clamp(0.0, 1.0); | |
| // Apply easing curve to move | |
| final easedMove = Curves.easeInOutCubic.transform(moveProgress); | |
| // Calculate current offset based on snap direction using lerpDouble | |
| final currentDx = snapped | |
| ? ui.lerpDouble(0, charAnim.dx, easedMove)! | |
| : ui.lerpDouble(charAnim.dx, 0, easedMove)!; | |
| final currentDy = snapped | |
| ? ui.lerpDouble(0, charAnim.dy, easedMove)! | |
| : ui.lerpDouble(charAnim.dy, 0, easedMove)!; | |
| // Calculate opacity using lerpDouble | |
| final opacity = snapped | |
| ? ui.lerpDouble(1.0, 0.0, fadeProgress)! | |
| : ui.lerpDouble(0.0, 1.0, fadeProgress)!; | |
| // Calculate blur using lerpDouble | |
| final currentBlur = snapped | |
| ? ui.lerpDouble(0, charAnim.blurRadius, blurProgress)! | |
| : ui.lerpDouble(charAnim.blurRadius, 0, blurProgress)!; | |
| // Calculate scale using lerpDouble | |
| final currentScale = snapped | |
| ? ui.lerpDouble(1.0, 0.8, scaleProgress)! | |
| : ui.lerpDouble(0.8, 1.0, scaleProgress)!; | |
| if (opacity <= 0) continue; | |
| // Save canvas state | |
| canvas.save(); | |
| // Translate to character position | |
| final finalX = startX + charOffset.dx + currentDx; | |
| final finalY = startY + charOffset.dy + currentDy; | |
| canvas.translate(finalX, finalY); | |
| // Apply scale | |
| canvas.scale(currentScale, currentScale); | |
| final foreground = | |
| Paint() // | |
| ..color = Colors.white.withValues(alpha: opacity); | |
| // Apply blur if needed | |
| if (currentBlur > 0) { | |
| foreground.maskFilter = ui.MaskFilter.blur( | |
| ui.BlurStyle.normal, | |
| ui.Shadow.convertRadiusToSigma(currentBlur), | |
| ); | |
| } | |
| // Paint the character | |
| final paragraphBuilder = | |
| ui.ParagraphBuilder( | |
| textStyle.getParagraphStyle( | |
| textAlign: TextAlign.start, | |
| textScaler: TextScaler.noScaling, | |
| textDirection: TextDirection.ltr, | |
| ), | |
| ) | |
| ..pushStyle(ui.TextStyle(foreground: foreground)) | |
| ..addText(charAnim.char); | |
| final paragraph = paragraphBuilder.build() | |
| ..layout(ui.ParagraphConstraints(width: double.infinity)); | |
| canvas.drawParagraph(paragraph, Offset.zero); | |
| canvas.restore(); | |
| } | |
| } | |
| @override | |
| bool shouldRepaint(AnimatedTextCustomPainter oldDelegate) { | |
| return oldDelegate.snapped != snapped || // | |
| oldDelegate.charAnimations != charAnimations; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment