import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; class AnimatedButton extends StatefulWidget { AnimatedButton({ Key? key, required this.text, required this.onPressed, required this.controller, this.textColor, required this.loadingColor, this.color, }) : super(key: key); final String text; final Color? color; final Color? textColor; final Color loadingColor; final VoidCallback? onPressed; final AnimationController controller; @override _AnimatedButtonState createState() => _AnimatedButtonState(); } class _AnimatedButtonState extends State with SingleTickerProviderStateMixin { late Animation _sizeAnimation; late Animation _textOpacityAnimation; late Animation _buttonOpacityAnimation; late Animation _ringThicknessAnimation; late Animation _ringOpacityAnimation; late Animation _colorAnimation; var _isLoading = false; var _hover = false; var _width = 120.0; late Color _color; late Color _loadingColor; static const _height = 40.0; static const _loadingCircleRadius = _height / 2; static const _loadingCircleThickness = 4.0; @override void initState() { super.initState(); _textOpacityAnimation = Tween(begin: 1.0, end: 0.0).animate( CurvedAnimation( parent: widget.controller, curve: Interval(0.0, .25), ), ); // _colorAnimation // _width, _sizeAnimation _buttonOpacityAnimation = Tween(begin: 1.0, end: 0.0).animate(CurvedAnimation( parent: widget.controller, curve: Threshold(.65), )); _ringThicknessAnimation = Tween(begin: _loadingCircleRadius, end: _loadingCircleThickness) .animate(CurvedAnimation( parent: widget.controller, curve: Interval(.65, .85), )); _ringOpacityAnimation = Tween(begin: 1.0, end: 0.0).animate(CurvedAnimation( parent: widget.controller, curve: Interval(.85, 1.0), )); widget.controller.addStatusListener(handleStatusChanged); } @override void didChangeDependencies() { _updateColorAnimation(); _updateWidth(); super.didChangeDependencies(); } void _updateColorAnimation() { final theme = Theme.of(context); final buttonTheme = theme.floatingActionButtonTheme; _color = (widget.color ?? buttonTheme.backgroundColor)!; _loadingColor = widget.loadingColor ?? theme.colorScheme.secondary; _colorAnimation = ColorTween( begin: _color, end: _loadingColor, ).animate( CurvedAnimation( parent: widget.controller, curve: const Interval(0.0, .65, curve: Curves.fastOutSlowIn), ), ); } @override void didUpdateWidget(AnimatedButton oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.color != widget.color || oldWidget.loadingColor != widget.loadingColor) { _updateColorAnimation(); } if (oldWidget.text != widget.text) { _updateWidth(); } } @override void dispose() { super.dispose(); widget.controller.removeStatusListener(handleStatusChanged); } void handleStatusChanged(status) { if (status == AnimationStatus.forward) { setState(() => _isLoading = true); } if (status == AnimationStatus.dismissed) { setState(() => _isLoading = false); } } /// sets width and size animation void _updateWidth() { final theme = Theme.of(context); final fontSize = theme.textTheme.button!.fontSize; final renderParagraph = RenderParagraph( TextSpan( text: widget.text, style: TextStyle( fontSize: fontSize, fontWeight: theme.textTheme.button!.fontWeight, letterSpacing: theme.textTheme.button!.letterSpacing, ), ), textDirection: TextDirection.ltr, maxLines: 1, ); renderParagraph.layout(BoxConstraints(minWidth: 120.0)); // text width based on fontSize, plus 45.0 for padding var textWidth = renderParagraph.getMinIntrinsicWidth(fontSize!).ceilToDouble() + 45.0; // button width is min 120.0 and max 240.0 _width = textWidth > 120.0 && textWidth < 240.0 ? textWidth : textWidth >= 240.0 ? 240.0 : 120.0; _sizeAnimation = Tween(begin: 1.0, end: _height / _width) .animate(CurvedAnimation( parent: widget.controller, curve: Interval(0.0, .65, curve: Curves.fastOutSlowIn), )); } Widget _buildButtonText(ThemeData theme) { return FadeTransition( opacity: _textOpacityAnimation, child: AnimatedText( text: widget.text, style: TextStyle(color: widget.textColor ?? Colors.white), ), ); } Widget _buildButton(ThemeData theme) { final buttonTheme = theme.floatingActionButtonTheme; return FadeTransition( opacity: _buttonOpacityAnimation, child: AnimatedContainer( duration: Duration(milliseconds: 300), child: AnimatedBuilder( animation: _colorAnimation, builder: (context, child) => Material( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_height / 2)), color: _colorAnimation.value, child: child, shadowColor: _color, elevation: (_isLoading == false) ? (_hover == true ? buttonTheme.highlightElevation ?? 0.0 : buttonTheme.elevation ?? 0.0) : 0.0, ), child: InkWell( onTap: !_isLoading ? widget!.onPressed : null, splashColor: buttonTheme.splashColor, customBorder: buttonTheme.shape, onHighlightChanged: (value) => setState(() => _hover = value), child: SizeTransition( sizeFactor: _sizeAnimation, axis: Axis.horizontal, child: Container( width: _width, height: _height, alignment: Alignment.center, child: _buildButtonText(theme), ), ), ), ), ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Stack( alignment: Alignment.center, children: [ FadeTransition( opacity: _ringOpacityAnimation, child: AnimatedBuilder( animation: _ringThicknessAnimation, builder: (context, child) => Ring( color: widget!.loadingColor, size: _height, thickness: _ringThicknessAnimation.value, ), ), ), if (_isLoading) SizedBox( width: _height - _loadingCircleThickness, height: _height - _loadingCircleThickness, child: CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(widget.loadingColor), // backgroundColor: Colors.red, strokeWidth: _loadingCircleThickness, ), ), _buildButton(theme), ], ); } } class Ring extends StatelessWidget { Ring({ Key? key, required this.color, this.size = 40.0, this.thickness = 2.0, this.value = 1.0, }) : assert(size - thickness > 0), assert(thickness >= 0), super(key: key); final Color color; final double size; final double thickness; final double value; @override Widget build(BuildContext context) { return SizedBox( width: size - thickness, height: size - thickness, child: thickness == 0 ? null : CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(color), strokeWidth: thickness, value: value, ), ); } } enum AnimatedTextRotation { up, down } /// https://medium.com/flutter-community/flutter-challenge-3d-bottom-navigation-bar-48952a5fd996 class AnimatedText extends StatefulWidget { AnimatedText({ Key? key, required this.text, required this.style, this.textRotation = AnimatedTextRotation.up, }) : super(key: key); final String text; final TextStyle style; final AnimatedTextRotation textRotation; @override _AnimatedTextState createState() => _AnimatedTextState(); } class _AnimatedTextState extends State with SingleTickerProviderStateMixin { var _newText = ''; var _oldText = ''; var _layoutHeight = 0.0; final _textKey = GlobalKey(); late Animation _animation; late AnimationController _controller; double get radius => _layoutHeight / 2; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), ); _animation = Tween(begin: 0.0, end: pi / 2).animate(CurvedAnimation( parent: _controller, curve: Curves.easeOutBack, )); _oldText = widget.text; WidgetsBinding.instance.addPostFrameCallback((_) { setState(() => _layoutHeight = getWidgetSize(_textKey)!.height); }); } @override void didUpdateWidget(AnimatedText oldWidget) { super.didUpdateWidget(oldWidget); if (widget.text != oldWidget.text) { _oldText = oldWidget.text; _newText = widget.text; _controller.forward().then((_) { setState(() { final t = _oldText; _oldText = _newText; _newText = t; }); _controller.reset(); }); } } @override void dispose() { super.dispose(); _controller.dispose(); } Matrix4 get _matrix { // Fix: The text is not centered after applying perspective effect in the web build. Idk why if (kIsWeb) { return Matrix4.identity(); } return Matrix4.identity()..setEntry(3, 2, .006); } Matrix4 _getFrontSideUp(double value) { return _matrix ..translate( 0.0, -radius * sin(_animation.value), -radius * cos(_animation.value), ) ..rotateX(-_animation.value); // 0 -> -pi/2 } Matrix4 _getBackSideUp(double value) { return _matrix ..translate( 0.0, radius * cos(_animation.value), -radius * sin(_animation.value), ) ..rotateX((pi / 2) - _animation.value); // pi/2 -> 0 } Matrix4 _getFrontSideDown(double value) { return _matrix ..translate( 0.0, radius * sin(_animation.value), -radius * cos(_animation.value), ) ..rotateX(_animation.value); // 0 -> pi/2 } Matrix4 _getBackSideDown(double value) { return _matrix ..translate( 0.0, -radius * cos(_animation.value), -radius * sin(_animation.value), ) ..rotateX(_animation.value - pi / 2); // -pi/2 -> 0 } @override Widget build(BuildContext context) { final rollUp = widget.textRotation == AnimatedTextRotation.up; final oldText = Text( _oldText, key: _textKey, style: widget.style, ); final newText = Text( _newText, style: widget.style, ); return AnimatedBuilder( animation: _animation, builder: (context, child) => Stack( alignment: Alignment.center, children: [ if (_animation.value <= toRadian(85)) Transform( alignment: Alignment.center, transform: rollUp ? _getFrontSideUp(_animation.value) : _getFrontSideDown(_animation.value), child: oldText, ), if (_animation.value >= toRadian(5)) Transform( alignment: Alignment.center, transform: rollUp ? _getBackSideUp(_animation.value) : _getBackSideDown(_animation.value), child: newText, ), ], ), ); } // Helpers double toRadian(double degree) => degree * pi / 180; double lerp(double start, double end, double percent) => (start + percent * (end - start)); Size? getWidgetSize(GlobalKey key) { return key.currentContext!.size; //return renderBox?.size; } }