feat: UX-002 — BouncingLoader widget replacing CircularProgressIndicator in key screens
This commit is contained in:
99
lib/widgets/bouncing_loader.dart
Normal file
99
lib/widgets/bouncing_loader.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
// lib/widgets/bouncing_loader.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Three-dot bouncing loader using Curves.bounceOut.
|
||||
/// Drop-in replacement for CircularProgressIndicator on full-screen loads.
|
||||
class BouncingLoader extends StatefulWidget {
|
||||
final Color? color;
|
||||
final double dotSize;
|
||||
final double spacing;
|
||||
|
||||
const BouncingLoader({
|
||||
Key? key,
|
||||
this.color,
|
||||
this.dotSize = 8.0,
|
||||
this.spacing = 6.0,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<BouncingLoader> createState() => _BouncingLoaderState();
|
||||
}
|
||||
|
||||
class _BouncingLoaderState extends State<BouncingLoader> with TickerProviderStateMixin {
|
||||
late final List<AnimationController> _controllers;
|
||||
late final List<Animation<double>> _animations;
|
||||
|
||||
static const _duration = Duration(milliseconds: 600);
|
||||
static const _staggerDelay = Duration(milliseconds: 200);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controllers = List.generate(
|
||||
3,
|
||||
(i) => AnimationController(vsync: this, duration: _duration),
|
||||
);
|
||||
_animations = _controllers.map((c) {
|
||||
return Tween<double>(begin: 0.0, end: -12.0).animate(
|
||||
CurvedAnimation(parent: c, curve: Curves.bounceOut),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
_startWithStagger();
|
||||
}
|
||||
|
||||
void _startWithStagger() async {
|
||||
for (int i = 0; i < _controllers.length; i++) {
|
||||
await Future.delayed(i == 0 ? Duration.zero : _staggerDelay);
|
||||
if (!mounted) return;
|
||||
_startLoop(i);
|
||||
}
|
||||
}
|
||||
|
||||
void _startLoop(int index) {
|
||||
if (!mounted) return;
|
||||
_controllers[index].forward(from: 0).whenComplete(() {
|
||||
if (mounted) {
|
||||
Future.delayed(
|
||||
Duration(milliseconds: _staggerDelay.inMilliseconds * (_controllers.length - 1)),
|
||||
() { if (mounted) _startLoop(index); },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _controllers) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dotColor = widget.color ?? Theme.of(context).colorScheme.primary;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (i) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: widget.spacing / 2),
|
||||
child: AnimatedBuilder(
|
||||
animation: _animations[i],
|
||||
builder: (_, __) => Transform.translate(
|
||||
offset: Offset(0, _animations[i].value),
|
||||
child: Container(
|
||||
width: widget.dotSize,
|
||||
height: widget.dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: dotColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user