Created
October 14, 2025 20:23
-
-
Save BorisKest/faac0807a18d5fe09a6295ac83b05072 to your computer and use it in GitHub Desktop.
Jumping Letters Indicator
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
| /* | |
| * Jumping Letters Indicator | |
| * Based on DotsLoadingIndicator by Mike Matiunin | |
| * https://gist.github.com/PlugFox/e486dcaf99d958973a1f1b1cddea789b | |
| * Modified to animate text letters instead of dots | |
| * 14 October 2025 | |
| */ | |
| // ignore_for_file: curly_braces_in_flow_control_structures | |
| import 'dart:typed_data'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/rendering.dart' show PipelineOwner, SemanticsConfiguration; | |
| import 'package:flutter/scheduler.dart' show Ticker; | |
| import 'package:meta/meta.dart' show internal; | |
| /// {@template jumping_letters_indicator} | |
| /// JumpingLettersIndicator widget. | |
| /// A loading indicator where each letter of the provided text jumps up and down | |
| /// with a phase offset to create a wave-like animation effect. | |
| /// The color of the letters can be customized with [color]. | |
| /// The speed of the animation can be adjusted with [speed]. | |
| /// The animation curve can be customized with [curve]. | |
| /// If [boundary] is true, the widget will be a repaint boundary. | |
| /// {@endtemplate} | |
| class JumpingLettersIndicator extends LeafRenderObjectWidget { | |
| /// Creates a JumpingLettersIndicator widget. | |
| /// | |
| /// {@macro jumping_letters_indicator} | |
| const JumpingLettersIndicator({ | |
| required this.text, | |
| this.style, | |
| this.color, | |
| this.speed = 1.0, | |
| this.curve = Curves.easeInOut, | |
| this.jumpHeight = 0.3, | |
| this.letterSpacing = 0.0, | |
| this.boundary = false, | |
| super.key, | |
| }); | |
| /// The text to animate. Each letter will jump independently. | |
| final String text; | |
| /// Text style for the letters. | |
| final TextStyle? style; | |
| /// The color used for the letters. | |
| /// If null, it will use the style color or the default text color. | |
| final Color? color; | |
| /// Speed of the animation. | |
| final double speed; | |
| /// Animation curve. | |
| final Curve curve; | |
| /// Height of the jump as a factor of the text height. | |
| /// 0.3 means letters jump 30% of their height. | |
| final double jumpHeight; | |
| /// Additional spacing between letters. | |
| final double letterSpacing; | |
| /// Whether to make this widget a repaint boundary. | |
| /// Defaults to false. | |
| final bool boundary; | |
| /// Helper method to compute the effective color for the letters. | |
| static Color _getEffectiveColor(BuildContext context, Color? color, TextStyle? style) { | |
| final defaultTextStyle = DefaultTextStyle.of(context).style; | |
| return color ?? style?.color ?? defaultTextStyle.color ?? const Color(0xFF000000); | |
| } | |
| /// Helper method to compute the effective text style. | |
| static TextStyle _getEffectiveTextStyle(BuildContext context, TextStyle? style, Color effectiveColor) { | |
| final defaultTextStyle = DefaultTextStyle.of(context).style; | |
| return defaultTextStyle.merge(style).copyWith(color: effectiveColor); | |
| } | |
| @override | |
| RenderObject createRenderObject(BuildContext context) { | |
| final effectiveColor = _getEffectiveColor(context, color, style); | |
| final effectiveStyle = _getEffectiveTextStyle(context, style, effectiveColor); | |
| final textDirection = Directionality.maybeOf(context); | |
| return JumpingLettersIndicator$RenderObject() | |
| ..text = text | |
| ..textStyle = effectiveStyle | |
| ..speed = speed | |
| ..curve = curve | |
| ..jumpHeight = jumpHeight | |
| ..letterSpacing = letterSpacing | |
| ..textDirection = textDirection ?? TextDirection.ltr | |
| ..boundary = boundary; | |
| } | |
| @override | |
| void updateRenderObject(BuildContext context, covariant JumpingLettersIndicator$RenderObject renderObject) { | |
| final effectiveColor = _getEffectiveColor(context, color, style); | |
| final effectiveStyle = _getEffectiveTextStyle(context, style, effectiveColor); | |
| final textDirection = Directionality.of(context); | |
| final needsLayout = | |
| renderObject.text != text || | |
| renderObject.textStyle != effectiveStyle || | |
| renderObject.letterSpacing != letterSpacing || | |
| renderObject.textDirection != textDirection; | |
| final needsRepaintOnly = | |
| renderObject.speed != speed || renderObject.curve != curve || renderObject.jumpHeight != jumpHeight; | |
| renderObject | |
| ..text = text | |
| ..textStyle = effectiveStyle | |
| ..speed = speed | |
| ..curve = curve | |
| ..jumpHeight = jumpHeight | |
| ..letterSpacing = letterSpacing | |
| ..textDirection = textDirection | |
| ..boundary = boundary; | |
| if (needsLayout) { | |
| renderObject.markNeedsLayout(); | |
| } else if (needsRepaintOnly) { | |
| renderObject.markNeedsPaint(); | |
| } | |
| // Re-evaluate ticker state when inputs change | |
| renderObject.evaluateTicker(); | |
| } | |
| } | |
| @internal | |
| class JumpingLettersIndicator$RenderObject extends RenderBox with WidgetsBindingObserver { | |
| JumpingLettersIndicator$RenderObject(); | |
| // --- Animation constants --- // | |
| static const double letterPhaseOffset = 0.1; // Phase offset between letters | |
| static const double defaultJumpHeight = 0.3; // Default jump height factor | |
| /// The text to animate. | |
| String text = ''; | |
| /// Text style for the letters. | |
| TextStyle textStyle = const TextStyle(); | |
| /// Speed of the animation. | |
| double speed = 1.0; | |
| /// Animation curve. | |
| Curve curve = Curves.easeInOut; | |
| /// Height of the jump as a factor of the text height. | |
| double jumpHeight = defaultJumpHeight; | |
| /// Additional spacing between letters. | |
| double letterSpacing = 0.0; | |
| /// Text direction used by the internal TextPainter. | |
| TextDirection textDirection = TextDirection.ltr; | |
| /// Whether to make this widget a repaint boundary. | |
| bool boundary = false; | |
| /// Animation loop ticker. | |
| Ticker? _ticker; | |
| /// Text painters for each letter. | |
| List<TextPainter> _letterPainters = []; | |
| /// Positions for each letter (x, y pairs). | |
| Float32List _letterPositions = Float32List(0); | |
| /// Base positions for each letter (without jump offset). | |
| Float32List _basePositions = Float32List(0); | |
| /// Total amount of time passed since the animation loop was started. | |
| Duration _lastFrameTime = Duration.zero; | |
| /// Cached size of the widget. | |
| Size _cachedSize = Size.zero; | |
| /// Whether animations are enabled (not disabled by accessibility settings). | |
| bool get _animationsEnabled => | |
| !WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.disableAnimations && speed > 0.0; | |
| @override | |
| bool get isRepaintBoundary => boundary; | |
| @override | |
| bool get alwaysNeedsCompositing => false; | |
| @override | |
| bool get sizedByParent => false; | |
| @override | |
| void attach(PipelineOwner owner) { | |
| super.attach(owner); | |
| WidgetsBinding.instance.addObserver(this); | |
| _ensureTickerState(); | |
| } | |
| @override | |
| void detach() { | |
| _ticker?.dispose(); | |
| _ticker = null; | |
| _disposePainters(); | |
| WidgetsBinding.instance.removeObserver(this); | |
| super.detach(); | |
| } | |
| @override | |
| void didChangeAccessibilityFeatures() { | |
| super.didChangeAccessibilityFeatures(); | |
| _ensureTickerState(); | |
| } | |
| void _ensureTickerState() { | |
| if (_animationsEnabled) { | |
| _ticker ??= Ticker(_onTick, debugLabel: 'JumpingLettersIndicator'); | |
| if (!_ticker!.isActive) _ticker!.start(); | |
| } else { | |
| _ticker?.stop(); | |
| } | |
| } | |
| /// Public hook to re-evaluate ticker state when external inputs change. | |
| void evaluateTicker() => _ensureTickerState(); | |
| void _disposePainters() { | |
| for (final painter in _letterPainters) { | |
| painter.dispose(); | |
| } | |
| _letterPainters.clear(); | |
| } | |
| void _setupPainters() { | |
| _disposePainters(); | |
| if (text.isEmpty) return; | |
| _letterPainters = List.generate(text.length, (index) { | |
| final letter = text[index]; | |
| return TextPainter( | |
| text: TextSpan(text: letter, style: textStyle), | |
| textDirection: textDirection, | |
| maxLines: 1, | |
| )..layout(); | |
| }); | |
| // Initialize position arrays | |
| final count = text.length; | |
| _letterPositions = Float32List(count * 2); | |
| _basePositions = Float32List(count * 2); | |
| } | |
| @override | |
| void performLayout() { | |
| size = computeDryLayout(constraints); | |
| } | |
| @override | |
| Size computeDryLayout(BoxConstraints constraints) { | |
| if (text.isEmpty) { | |
| _cachedSize = Size.zero; | |
| return _cachedSize; | |
| } | |
| _setupPainters(); | |
| _layoutLetters(constraints); | |
| return _cachedSize; | |
| } | |
| void _layoutLetters(BoxConstraints constraints) { | |
| if (_letterPainters.isEmpty) return; | |
| double totalWidth = 0; | |
| double maxHeight = 0; | |
| // Calculate base positions and total dimensions | |
| double currentX = 0; | |
| for (int i = 0; i < _letterPainters.length; i++) { | |
| final painter = _letterPainters[i]; | |
| final letterWidth = painter.width; | |
| final letterHeight = painter.height; | |
| _basePositions[i * 2] = currentX; | |
| _basePositions[i * 2 + 1] = 0; | |
| currentX += letterWidth + letterSpacing; | |
| totalWidth = currentX - letterSpacing; | |
| maxHeight = maxHeight > letterHeight ? maxHeight : letterHeight; | |
| } | |
| // Add jump space to height | |
| final jumpSpace = maxHeight * jumpHeight; | |
| final totalHeight = maxHeight + jumpSpace; | |
| _cachedSize = constraints.constrain(Size(totalWidth, totalHeight)); | |
| // Adjust positions if text is clipped | |
| if (_cachedSize.width < totalWidth) { | |
| final scale = _cachedSize.width / totalWidth; | |
| for (int i = 0; i < _letterPainters.length; i++) { | |
| _basePositions[i * 2] *= scale; | |
| } | |
| } | |
| _relayoutLetters(); | |
| } | |
| /// Update letter positions based on current animation time. | |
| void _relayoutLetters() { | |
| if (_letterPainters.isEmpty) return; | |
| final elapsed = _lastFrameTime.inMicroseconds * 0.000001 * speed; | |
| final maxHeight = _cachedSize.height - (_cachedSize.height * jumpHeight); | |
| for (int i = 0; i < _letterPainters.length; i++) { | |
| final phase = (elapsed - (i * letterPhaseOffset)) % 1.0; | |
| final localT = phase < 0.5 ? phase * 2 : (1.0 - phase) * 2; | |
| final eased = curve.transform(localT.clamp(0.0, 1.0)); | |
| final jumpOffset = -eased * _cachedSize.height * jumpHeight; | |
| _letterPositions[i * 2] = _basePositions[i * 2]; | |
| _letterPositions[i * 2 + 1] = maxHeight + jumpOffset; | |
| } | |
| } | |
| /// This method is periodically invoked by the [_ticker]. | |
| void _onTick(Duration elapsed) { | |
| if (!attached || _cachedSize.isEmpty) return; | |
| _lastFrameTime = elapsed; | |
| _relayoutLetters(); | |
| markNeedsPaint(); | |
| } | |
| @override | |
| void paint(PaintingContext context, Offset offset) { | |
| if (size.isEmpty || _letterPainters.isEmpty) return; | |
| final canvas = context.canvas; | |
| canvas | |
| ..save() | |
| ..translate(offset.dx, offset.dy) | |
| ..clipRect(Offset.zero & size); | |
| // Paint each letter at its animated position | |
| for (int i = 0; i < _letterPainters.length; i++) { | |
| final painter = _letterPainters[i]; | |
| final x = _letterPositions[i * 2]; | |
| final y = _letterPositions[i * 2 + 1] - painter.height / 1.5; // Adjust for baseline | |
| painter.paint(canvas, Offset(x, y)); | |
| } | |
| canvas.restore(); | |
| } | |
| @override | |
| void describeSemanticsConfiguration(SemanticsConfiguration config) { | |
| super.describeSemanticsConfiguration(config); | |
| if (!config.isSemanticBoundary) return; | |
| config | |
| ..isMergingSemanticsOfDescendants = true | |
| ..label = text.isNotEmpty ? '$text...' : 'Loading...' | |
| ..liveRegion = true; | |
| } | |
| } | |
| // ============================================================================ | |
| // Demo / Example Usage | |
| // ============================================================================ | |
| void main() => runApp(const JumpingLettersLoadingIndicatorDemo()); | |
| class JumpingLettersLoadingIndicatorDemo extends StatelessWidget { | |
| const JumpingLettersLoadingIndicatorDemo({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return const MaterialApp(debugShowCheckedModeBanner: false, home: JumpingLettersDemoScreen()); | |
| } | |
| } | |
| class JumpingLettersDemoScreen extends StatefulWidget { | |
| const JumpingLettersDemoScreen({super.key}); | |
| @override | |
| State<JumpingLettersDemoScreen> createState() => _JumpingLettersDemoScreenState(); | |
| } | |
| class _JumpingLettersDemoScreenState extends State<JumpingLettersDemoScreen> { | |
| double _speed = 1.0; | |
| double _jumpHeight = 0.3; | |
| double _letterSpacing = 0.0; | |
| Curve _curve = Curves.easeInOut; | |
| String _customText = 'LOADING'; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar( | |
| title: const Text('Jumping Letters Demo'), | |
| backgroundColor: Colors.purple, | |
| foregroundColor: Colors.white, | |
| ), | |
| body: SingleChildScrollView( | |
| padding: const EdgeInsets.all(24.0), | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| // Interactive example | |
| _buildExample( | |
| 'Interactive Example', | |
| Center( | |
| child: JumpingLettersIndicator( | |
| text: _customText, | |
| speed: _speed, | |
| curve: _curve, | |
| jumpHeight: _jumpHeight, | |
| letterSpacing: _letterSpacing, | |
| style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.purple), | |
| ), | |
| ), | |
| ), | |
| // Controls | |
| Card( | |
| child: Padding( | |
| padding: const EdgeInsets.all(16.0), | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| // Text input | |
| TextField( | |
| decoration: const InputDecoration(labelText: 'Custom Text', border: OutlineInputBorder()), | |
| onSubmitted: (value) => setState(() => _customText = value.isNotEmpty ? value : 'LOADING'), | |
| controller: TextEditingController(text: _customText), | |
| ), | |
| const SizedBox(height: 16), | |
| // Speed control | |
| Text('Speed: ${_speed.toStringAsFixed(1)}', style: const TextStyle(fontWeight: FontWeight.bold)), | |
| Slider( | |
| value: _speed, | |
| min: 0.0, | |
| max: 3.0, | |
| divisions: 30, | |
| label: _speed.toStringAsFixed(1), | |
| onChanged: (value) => setState(() => _speed = value), | |
| ), | |
| // Jump height control | |
| Text( | |
| 'Jump Height: ${_jumpHeight.toStringAsFixed(2)}', | |
| style: const TextStyle(fontWeight: FontWeight.bold), | |
| ), | |
| Slider( | |
| value: _jumpHeight, | |
| min: 0.0, | |
| max: 1.0, | |
| divisions: 20, | |
| label: _jumpHeight.toStringAsFixed(2), | |
| onChanged: (value) => setState(() => _jumpHeight = value), | |
| ), | |
| // Letter spacing control | |
| Text( | |
| 'Letter Spacing: ${_letterSpacing.toStringAsFixed(1)}', | |
| style: const TextStyle(fontWeight: FontWeight.bold), | |
| ), | |
| Slider( | |
| value: _letterSpacing, | |
| min: 0.0, | |
| max: 20.0, | |
| divisions: 20, | |
| label: _letterSpacing.toStringAsFixed(1), | |
| onChanged: (value) => setState(() => _letterSpacing = value), | |
| ), | |
| const SizedBox(height: 16), | |
| const Text('Animation Curve:', style: TextStyle(fontWeight: FontWeight.bold)), | |
| Wrap( | |
| spacing: 8, | |
| children: [ | |
| _buildCurveChip('easeInOut', Curves.easeInOut), | |
| _buildCurveChip('linear', Curves.linear), | |
| _buildCurveChip('bounceOut', Curves.bounceOut), | |
| _buildCurveChip('elasticOut', Curves.elasticOut), | |
| ], | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| const SizedBox(height: 32), | |
| // Predefined examples | |
| _buildExample( | |
| 'Default Style', | |
| const Center( | |
| child: JumpingLettersIndicator( | |
| text: 'HELLO WORLD', | |
| style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), | |
| ), | |
| ), | |
| ), | |
| _buildExample( | |
| 'Custom Colors', | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.spaceEvenly, | |
| children: const [ | |
| JumpingLettersIndicator( | |
| text: 'RED', | |
| color: Colors.red, | |
| style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), | |
| ), | |
| JumpingLettersIndicator( | |
| text: 'GREEN', | |
| color: Colors.green, | |
| style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), | |
| ), | |
| JumpingLettersIndicator( | |
| text: 'BLUE', | |
| color: Colors.blue, | |
| style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), | |
| ), | |
| ], | |
| ), | |
| ), | |
| _buildExample( | |
| 'Different Speeds', | |
| Column( | |
| children: const [ | |
| JumpingLettersIndicator( | |
| text: 'SLOW (0.5x)', | |
| speed: 0.5, | |
| style: TextStyle(fontSize: 16, color: Colors.brown), | |
| ), | |
| SizedBox(height: 16), | |
| JumpingLettersIndicator( | |
| text: 'NORMAL (1.0x)', | |
| speed: 1.0, | |
| style: TextStyle(fontSize: 16, color: Colors.black), | |
| ), | |
| SizedBox(height: 16), | |
| JumpingLettersIndicator( | |
| text: 'FAST (2.0x)', | |
| speed: 2.0, | |
| style: TextStyle(fontSize: 16, color: Colors.orange), | |
| ), | |
| ], | |
| ), | |
| ), | |
| _buildExample( | |
| 'Different Jump Heights', | |
| Column( | |
| children: const [ | |
| JumpingLettersIndicator( | |
| text: 'SMALL JUMP', | |
| jumpHeight: 0.1, | |
| style: TextStyle(fontSize: 16, color: Colors.teal), | |
| ), | |
| SizedBox(height: 16), | |
| JumpingLettersIndicator( | |
| text: 'MEDIUM JUMP', | |
| jumpHeight: 0.3, | |
| style: TextStyle(fontSize: 16, color: Colors.indigo), | |
| ), | |
| SizedBox(height: 16), | |
| JumpingLettersIndicator( | |
| text: 'BIG JUMP', | |
| jumpHeight: 0.6, | |
| style: TextStyle(fontSize: 16, color: Colors.pink), | |
| ), | |
| ], | |
| ), | |
| ), | |
| _buildExample( | |
| 'With Letter Spacing', | |
| const Center( | |
| child: JumpingLettersIndicator( | |
| text: 'S P A C E D', | |
| letterSpacing: 8.0, | |
| style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.deepPurple), | |
| ), | |
| ), | |
| ), | |
| _buildExample( | |
| 'Bouncy Animation', | |
| const Center( | |
| child: JumpingLettersIndicator( | |
| text: 'BOUNCY', | |
| curve: Curves.bounceOut, | |
| style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.orange), | |
| ), | |
| ), | |
| ), | |
| _buildExample( | |
| 'Elastic Animation', | |
| const Center( | |
| child: JumpingLettersIndicator( | |
| text: 'ELASTIC', | |
| curve: Curves.elasticOut, | |
| jumpHeight: 0.4, | |
| style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green), | |
| ), | |
| ), | |
| ), | |
| _buildExample( | |
| 'Numbers and Symbols', | |
| const Center( | |
| child: JumpingLettersIndicator( | |
| text: '123...ABC!', | |
| style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.red), | |
| ), | |
| ), | |
| ), | |
| _buildExample( | |
| 'Long Text', | |
| const JumpingLettersIndicator( | |
| text: 'The quick brown fox jumps over the lazy dog', | |
| style: TextStyle(fontSize: 14, color: Colors.black87), | |
| letterSpacing: 1.0, | |
| ), | |
| ), | |
| _buildExample( | |
| 'Paused (speed = 0)', | |
| const Center( | |
| child: JumpingLettersIndicator( | |
| text: 'PAUSED', | |
| speed: 0.0, | |
| style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.grey), | |
| ), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| Widget _buildCurveChip(String label, Curve curve) { | |
| final isSelected = _curve == curve; | |
| return ChoiceChip( | |
| label: Text(label), | |
| selected: isSelected, | |
| onSelected: (selected) { | |
| if (selected) setState(() => _curve = curve); | |
| }, | |
| ); | |
| } | |
| Widget _buildExample(String title, Widget child) { | |
| return Padding( | |
| padding: const EdgeInsets.only(bottom: 24.0), | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| Text( | |
| title, | |
| style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.black54), | |
| ), | |
| const SizedBox(height: 8), | |
| Container( | |
| width: double.infinity, | |
| padding: const EdgeInsets.all(16), | |
| decoration: BoxDecoration( | |
| color: Colors.grey.shade100, | |
| borderRadius: BorderRadius.circular(8), | |
| border: Border.all(color: Colors.grey.shade300), | |
| ), | |
| child: child, | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment