Skip to content

Instantly share code, notes, and snippets.

@contactjavas
Last active July 14, 2022 23:51
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save contactjavas/7a5db7e083ecba0362e08603d62c5aec to your computer and use it in GitHub Desktop.
Save contactjavas/7a5db7e083ecba0362e08603d62c5aec to your computer and use it in GitHub Desktop.
easy_loading_button demo
import 'package:flutter/material.dart';
/// begin_library_part
///
/// When writing this example, DartPad didn't support (many/almost all) custom packages
/// If you want to directly check for the related code (not the library),
/// then please search (in this DartPad) for this keyword: begin_example_part
enum EasyButtonState {
idle,
loading,
}
enum EasyButtonType {
elevated,
outlined,
text,
}
class EasyButton extends StatefulWidget {
/// Content inside the button when the button state is idle.
final Widget idleStateWidget;
/// Content inside the button when the button state is loading.
final Widget loadingStateWidget;
/// The button type.
final EasyButtonType type;
/// Whether or not to animate the width of the button. Default is `true`.
///
/// If this is set to `false`, you might want to set the `useEqualLoadingStateWidgetDimension` parameter to `true`.
final bool useWidthAnimation;
/// Whether or not to force the `loadingStateWidget` to have equal dimension.
///
/// This is useful when you are using `CircularProgressIndicator` as the `loadingStateWidget`.
///
/// This parameter might also be useful when you set the `useWidthAnimation` parameter to `true` combined with `CircularProgressIndicator` as the value for `loadingStateWidget`.
final bool useEqualLoadingStateWidgetDimension;
/// The button width.
final double width;
/// The button height.
final double height;
/// The gap between button and it's content.
///
/// This will be ignored when the `type` parameter value is set to `EasyButtonType.text`
final double contentGap;
/// The visual border radius of the button.
final double borderRadius;
/// The elevation of the button.
///
/// This will only be applied when the `type` parameter value is `EasyButtonType.elevated`
final double elevation;
/// Color for the button.
///
/// For [`EasyButtonType.elevated`]: This will be the background color.
///
/// For [`EasyButtonType.outlined`]: This will be the border color.
///
/// For [`EasyButtonType.text`]: This will be the text color.
final Color buttonColor;
/// Function to run when button is pressed.
final Function? onPressed;
const EasyButton({
Key? key,
required this.idleStateWidget,
required this.loadingStateWidget,
this.type = EasyButtonType.elevated,
this.useWidthAnimation = true,
this.useEqualLoadingStateWidgetDimension = true,
this.width = double.infinity,
this.height = 40.0,
this.contentGap = 0.0,
this.borderRadius = 0.0,
this.elevation = 0.0,
this.buttonColor = Colors.blueAccent,
this.onPressed,
}) : super(key: key);
@override
State createState() => _EasyButtonState();
}
class _EasyButtonState extends State<EasyButton> with TickerProviderStateMixin {
final GlobalKey _globalKey = GlobalKey();
Animation? _anim;
AnimationController? _animController;
final Duration _duration = const Duration(
milliseconds: 250,
);
EasyButtonState _state = EasyButtonState.idle;
late double _width;
late double _height;
late double _borderRadius;
@override
dispose() {
if (_animController != null) {
_animController!.dispose();
}
super.dispose();
}
@override
void deactivate() {
_reset();
super.deactivate();
}
@override
void initState() {
_reset();
super.initState();
}
void _reset() {
_state = EasyButtonState.idle;
_width = widget.width;
_height = widget.height;
_borderRadius = widget.borderRadius;
}
@override
Widget build(BuildContext context) {
return PhysicalModel(
color: Colors.transparent,
borderRadius: BorderRadius.circular(_borderRadius),
child: SizedBox(
key: _globalKey,
height: _height,
width: _width,
child: _buildChild(context),
),
);
}
Widget _buildChild(BuildContext context) {
var padding = EdgeInsets.all(
widget.contentGap,
);
var buttonColor = widget.buttonColor;
var shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_borderRadius),
);
final ButtonStyle elevatedButtonStyle = ElevatedButton.styleFrom(
padding: padding,
primary: buttonColor,
elevation: widget.elevation,
shape: shape,
);
final ButtonStyle outlinedButtonStyle = OutlinedButton.styleFrom(
padding: padding,
shape: shape,
side: BorderSide(
color: buttonColor,
),
);
final ButtonStyle textButtonStyle = TextButton.styleFrom(
padding: padding,
);
switch (widget.type) {
case EasyButtonType.elevated:
return ElevatedButton(
style: elevatedButtonStyle,
onPressed: _onButtonPressed(),
child: _buildChildren(context),
);
case EasyButtonType.outlined:
return TextButton(
style: outlinedButtonStyle,
onPressed: _onButtonPressed(),
child: _buildChildren(context),
);
case EasyButtonType.text:
return TextButton(
style: textButtonStyle,
onPressed: _onButtonPressed(),
child: _buildChildren(context),
);
}
}
Widget _buildChildren(BuildContext context) {
double contentGap =
widget.type == EasyButtonType.text ? 0.0 : widget.contentGap;
Widget contentWidget;
switch (_state) {
case EasyButtonState.idle:
contentWidget = widget.idleStateWidget;
break;
case EasyButtonState.loading:
contentWidget = widget.loadingStateWidget;
if (widget.useEqualLoadingStateWidgetDimension) {
contentWidget = SizedBox.square(
dimension: widget.height - (contentGap * 2),
child: widget.loadingStateWidget,
);
}
break;
}
return contentWidget;
}
VoidCallback _onButtonPressed() {
if (widget.onPressed == null) {
return () {};
}
return _manageLoadingState;
}
Future _manageLoadingState() async {
if (_state != EasyButtonState.idle) {
return;
}
// The result of widget.onPressed() will be called as VoidCallback after button status is back to default.
dynamic onIdle;
if (widget.useWidthAnimation) {
_toProcessing();
_forward((status) {
if (status == AnimationStatus.dismissed) {
_toDefault();
if (onIdle != null &&
(onIdle is VoidCallback || onIdle is FormFieldValidator)) {
onIdle();
}
}
});
onIdle = await widget.onPressed!();
_reverse();
} else {
_toProcessing();
onIdle = await widget.onPressed!();
_toDefault();
if (onIdle != null &&
(onIdle is VoidCallback || onIdle is FormFieldValidator)) {
onIdle();
}
}
}
void _toProcessing() {
setState(() {
_state = EasyButtonState.loading;
});
}
void _toDefault() {
if (mounted) {
setState(() {
_state = EasyButtonState.idle;
});
} else {
_state = EasyButtonState.idle;
}
}
void _forward(AnimationStatusListener stateListener) {
double initialWidth = _globalKey.currentContext!.size!.width;
double initialBorderRadius = widget.borderRadius;
double targetWidth = _height;
double targetBorderRadius = _height / 2;
_animController = AnimationController(duration: _duration, vsync: this);
_anim = Tween(begin: 0.0, end: 1.0).animate(_animController!)
..addListener(() {
setState(() {
_width = initialWidth - ((initialWidth - targetWidth) * _anim!.value);
_borderRadius = initialBorderRadius -
((initialBorderRadius - targetBorderRadius) * _anim!.value);
});
})
..addStatusListener(stateListener);
_animController!.forward();
}
void _reverse() {
_animController!.reverse();
}
}
/// end_library_part
/// begin_example_part
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Easy Loading Button',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const ExamplePage(title: 'Easy Loading Button'),
);
}
}
class ExamplePage extends StatefulWidget {
const ExamplePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<ExamplePage> createState() => _ExamplePageState();
}
class _ExamplePageState extends State<ExamplePage> {
@override
Widget build(BuildContext context) {
onButtonPressed() async {
await Future.delayed(const Duration(milliseconds: 3000), () => 42);
// After [onPressed], it will trigger animation running backwards, from end to beginning
return () {
// Optional returns is returning a VoidCallback that will be called
// after the animation is stopped at the beginning.
// A best practice would be to do time-consuming task in [onPressed],
// and do page navigation in the returned VoidCallback.
// So that user won't missed out the reverse animation.
};
}
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const SizedBox(
height: 15,
),
const Text(
'Elevated button',
),
const SizedBox(
height: 5,
),
EasyButton(
idleStateWidget: const Text(
'Elevated button',
style: TextStyle(
color: Colors.white,
),
),
loadingStateWidget: const CircularProgressIndicator(
strokeWidth: 3.0,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
useEqualLoadingStateWidgetDimension: true,
useWidthAnimation: false,
width: 150.0,
height: 40.0,
borderRadius: 4.0,
elevation: 2.0,
contentGap: 6.0,
buttonColor: Colors.blueAccent,
onPressed: onButtonPressed,
),
const SizedBox(
height: 15,
),
const Text(
'Elevated button (width animated)',
),
const SizedBox(
height: 5,
),
EasyButton(
idleStateWidget: const Text(
'Elevated button',
style: TextStyle(
color: Colors.white,
),
),
loadingStateWidget: const CircularProgressIndicator(
strokeWidth: 3.0,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
useWidthAnimation: true,
useEqualLoadingStateWidgetDimension: true,
width: 150.0,
height: 40.0,
borderRadius: 4.0,
contentGap: 6.0,
buttonColor: Colors.blueAccent,
onPressed: onButtonPressed,
),
const SizedBox(
height: 15,
),
const Text(
'Outlined button',
),
const SizedBox(
height: 5,
),
EasyButton(
type: EasyButtonType.outlined,
idleStateWidget: const Text(
'Outlined button',
style: TextStyle(
color: Colors.blueAccent,
),
),
loadingStateWidget: const CircularProgressIndicator(
strokeWidth: 3.0,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blueAccent,
),
),
useEqualLoadingStateWidgetDimension: true,
useWidthAnimation: false,
width: 150.0,
height: 40.0,
borderRadius: 4.0,
contentGap: 6.0,
onPressed: onButtonPressed,
),
const SizedBox(
height: 15,
),
const Text(
'Outlined button (width animated)',
),
const SizedBox(
height: 5,
),
EasyButton(
type: EasyButtonType.outlined,
idleStateWidget: const Text(
'Outlined button',
style: TextStyle(
color: Colors.blueAccent,
),
),
loadingStateWidget: const CircularProgressIndicator(
strokeWidth: 3.0,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blueAccent,
),
),
useWidthAnimation: true,
useEqualLoadingStateWidgetDimension: true,
width: 150.0,
height: 40.0,
borderRadius: 4.0,
contentGap: 6.0,
onPressed: onButtonPressed,
),
const SizedBox(
height: 15,
),
const Text(
'Text button',
),
const SizedBox(
height: 5,
),
EasyButton(
type: EasyButtonType.text,
idleStateWidget: const Text(
'Text button',
style: TextStyle(
color: Colors.blueAccent,
),
),
loadingStateWidget: const CircularProgressIndicator(
strokeWidth: 3.0,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blueAccent,
),
),
useEqualLoadingStateWidgetDimension: true,
useWidthAnimation: false,
width: 150.0,
height: 28.0,
borderRadius: 4.0,
onPressed: onButtonPressed,
),
const SizedBox(
height: 15,
),
const Text(
'Fullwidth elevated button',
),
const SizedBox(
height: 5,
),
EasyButton(
idleStateWidget: const Text(
'Fullwidth elevated button',
style: TextStyle(
color: Colors.white,
),
),
loadingStateWidget: const CircularProgressIndicator(
strokeWidth: 3.0,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
useEqualLoadingStateWidgetDimension: true,
useWidthAnimation: false,
width: double.infinity,
height: 40.0,
contentGap: 6.0,
buttonColor: Colors.blueAccent,
onPressed: onButtonPressed,
),
const SizedBox(
height: 15,
),
const Text(
'Fullwidth elevated button (width animated)',
),
const SizedBox(
height: 5,
),
EasyButton(
idleStateWidget: const Text(
'Fullwidth elevated button',
style: TextStyle(
color: Colors.white,
),
),
loadingStateWidget: const CircularProgressIndicator(
strokeWidth: 3.0,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
useWidthAnimation: true,
useEqualLoadingStateWidgetDimension: true,
width: double.infinity,
height: 40.0,
contentGap: 6.0,
buttonColor: Colors.blueAccent,
onPressed: onButtonPressed,
),
],
),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment