You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
467 lines
12 KiB
Dart
467 lines
12 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'dart:math';
|
|
|
|
import 'data_display/text.dart';
|
|
|
|
class AnimatedButton extends StatefulWidget {
|
|
AnimatedButton({
|
|
Key key,
|
|
@required this.text,
|
|
@required this.onPressed,
|
|
@required this.controller,
|
|
this.textColor,
|
|
this.loadingColor,
|
|
this.color,
|
|
}) : super(key: key);
|
|
|
|
final String text;
|
|
final Color color;
|
|
final Color textColor;
|
|
final Color loadingColor;
|
|
final Function onPressed;
|
|
final AnimationController controller;
|
|
|
|
@override
|
|
_AnimatedButtonState createState() => _AnimatedButtonState();
|
|
}
|
|
|
|
class _AnimatedButtonState extends State<AnimatedButton>
|
|
with SingleTickerProviderStateMixin {
|
|
Animation<double> _sizeAnimation;
|
|
Animation<double> _textOpacityAnimation;
|
|
Animation<double> _buttonOpacityAnimation;
|
|
Animation<double> _ringThicknessAnimation;
|
|
Animation<double> _ringOpacityAnimation;
|
|
Animation<Color> _colorAnimation;
|
|
var _isLoading = false;
|
|
var _hover = false;
|
|
var _width = 120.0;
|
|
|
|
Color _color;
|
|
Color _loadingColor;
|
|
|
|
static const _height = 40.0;
|
|
static const _loadingCircleRadius = _height / 2;
|
|
static const _loadingCircleThickness = 4.0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_textOpacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
|
CurvedAnimation(
|
|
parent: widget.controller,
|
|
curve: Interval(0.0, .25),
|
|
),
|
|
);
|
|
|
|
// _colorAnimation
|
|
// _width, _sizeAnimation
|
|
|
|
_buttonOpacityAnimation =
|
|
Tween<double>(begin: 1.0, end: 0.0).animate(CurvedAnimation(
|
|
parent: widget.controller,
|
|
curve: Threshold(.65),
|
|
));
|
|
|
|
_ringThicknessAnimation =
|
|
Tween<double>(begin: _loadingCircleRadius, end: _loadingCircleThickness)
|
|
.animate(CurvedAnimation(
|
|
parent: widget.controller,
|
|
curve: Interval(.65, .85),
|
|
));
|
|
_ringOpacityAnimation =
|
|
Tween<double>(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.accentColor;
|
|
|
|
_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<double>(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
|
|
? (_hover
|
|
? buttonTheme.highlightElevation
|
|
: buttonTheme.elevation)
|
|
: 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: <Widget>[
|
|
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<Color>(widget.loadingColor),
|
|
// backgroundColor: Colors.red,
|
|
strokeWidth: _loadingCircleThickness,
|
|
),
|
|
),
|
|
_buildButton(theme),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class Ring extends StatelessWidget {
|
|
Ring({
|
|
Key key,
|
|
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>(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,
|
|
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<AnimatedText>
|
|
with SingleTickerProviderStateMixin {
|
|
var _newText = '';
|
|
var _oldText = '';
|
|
var _layoutHeight = 0.0;
|
|
final _textKey = GlobalKey();
|
|
|
|
Animation<double> _animation;
|
|
AnimationController _controller;
|
|
|
|
double get radius => _layoutHeight / 2;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 500),
|
|
);
|
|
|
|
_animation = Tween<double>(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: <Widget>[
|
|
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) {
|
|
final RenderBox renderBox = key.currentContext?.findRenderObject();
|
|
return renderBox?.size;
|
|
}
|
|
}
|