Skip to content

Instantly share code, notes, and snippets.

@BorisKest
Created October 14, 2025 20:23
Show Gist options
  • Select an option

  • Save BorisKest/faac0807a18d5fe09a6295ac83b05072 to your computer and use it in GitHub Desktop.

Select an option

Save BorisKest/faac0807a18d5fe09a6295ac83b05072 to your computer and use it in GitHub Desktop.
Jumping Letters Indicator
/*
* 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