I created an expandable FAB button for my Flutter application with reading this Google Cookbook.
The control is it, same as Google version except Turkish typo:
import 'package:flutter/material.dart';
import 'dart:math' as math;
@immutable
class ExpandableFab extends StatefulWidget {
const ExpandableFab({
Key? key,
this.initialOpen,
required this.distance,
required this.children,
}) : super(key: key);
final bool? initialOpen;
final double distance;
final List<Widget> children;
@override
_ExpandableFabState createState() => _ExpandableFabState();
}
class _ExpandableFabState extends State<ExpandableFab>
with SingleTickerProviderStateMixin {
late final Animation<double> _expandAnimation;
bool _open = false;
late final AnimationController _controller;
final String moreText = 'İşlemleri görmek için basın.';
final String closeText = 'Kapatmak için basın.';
@override
void initState() {
super.initState();
_open = widget.initialOpen ?? false;
_controller = AnimationController(
value: _open ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
vsync: this,
);
_expandAnimation = CurvedAnimation(
curve: Curves.fastOutSlowIn,
reverseCurve: Curves.easeOutQuad,
parent: _controller,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_open = !_open;
if (_open) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: Stack(
alignment: Alignment.bottomRight,
clipBehavior: Clip.none,
children: [
_buildTapToCloseFab(),
..._buildExpandingActionButtons(),
_buildTapToOpenFab(),
],
),
);
}
Widget _buildTapToCloseFab() {
return SizedBox(
width: 56.0,
height: 56.0,
child: Tooltip(
message: closeText,
child: Center(
child: Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
elevation: 4.0,
child: InkWell(
onTap: _toggle,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
Icons.close,
color: Colors.grey,
size: 28,
),
),
),
),
),
),
);
}
List<Widget> _buildExpandingActionButtons() {
final children = <Widget>[];
final count = widget.children.length;
final step = 90.0 / (count - 1);
for (var i = 0, angleInDegrees = 0.0;
i < count;
i++, angleInDegrees += step) {
children.add(
_ExpandingActionButton(
directionInDegrees: angleInDegrees,
maxDistance: widget.distance,
progress: _expandAnimation,
child: widget.children[i],
),
);
}
return children;
}
Widget _buildTapToOpenFab() {
return IgnorePointer(
ignoring: _open,
child: AnimatedContainer(
transformAlignment: Alignment.center,
transform: Matrix4.diagonal3Values(
_open ? 0.7 : 1.0,
_open ? 0.7 : 1.0,
1.0,
),
duration: const Duration(milliseconds: 250),
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
child: AnimatedOpacity(
opacity: _open ? 0.0 : 1.0,
curve: const Interval(0.25, 1.0, curve: Curves.easeInOut),
duration: const Duration(milliseconds: 250),
child: FloatingActionButton(
tooltip: moreText,
onPressed: _toggle,
child: const Icon(Icons.more_horiz),
),
),
),
);
}
}
@immutable
class _ExpandingActionButton extends StatelessWidget {
_ExpandingActionButton({
Key? key,
required this.directionInDegrees,
required this.maxDistance,
required this.progress,
required this.child,
}) : super(key: key);
final double directionInDegrees;
final double maxDistance;
final Animation<double> progress;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: progress,
builder: (context, child) {
final offset = Offset.fromDirection(
directionInDegrees * (math.pi / 180.0),
progress.value * maxDistance,
);
return Positioned(
right: 4.0 + offset.dx,
bottom: 4.0 + offset.dy,
child: Transform.rotate(
angle: (1.0 - progress.value) * math.pi / 2,
child: child!,
),
);
},
child: FadeTransition(
opacity: progress,
child: child,
),
);
}
}
@immutable
class ActionButton extends StatelessWidget {
const ActionButton({
Key? key,
this.onPressed,
required this.icon,
required this.color,
required this.tooltip,
}) : super(key: key);
final VoidCallback? onPressed;
final Icon icon;
final Color color;
final String tooltip;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
color: color,
elevation: 4.0,
child: IconTheme.merge(
data: theme.accentIconTheme,
child: IconButton(
iconSize: 32,
icon: icon,
tooltip: tooltip,
onPressed: () {
onPressed!();
},
),
),
);
}
}
@immutable
class FakeItem extends StatelessWidget {
const FakeItem({
Key? key,
required this.isBig,
}) : super(key: key);
final bool isBig;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 24.0),
height: isBig ? 128.0 : 36.0,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: Colors.grey.shade300,
),
);
}
}
The UI state is it:
I use it as a FAB button on a Scaffold page like this:
return Scaffold(
floatingActionButton: ExpandableFab(
distance: 112.0,
children: [
ActionButton(
onPressed: () async {
print('Tapped add user.');
AddDialog addDialog = AddDialog(
primary: 'Kaydet',
secondary: 'İptal',
tooltip: 'Kullanıcı Ekle',
title: 'Kullanıcı Ekle',
key: Key('AddUser'),
);
bool result = await showDialog(
barrierDismissible: false,
context: this.context,
builder: (BuildContext context) {
return WillPopScope(
onWillPop: () async => false,
child: addDialog,
);
},
);
if (result) {
setState(() {});
}
},
icon: const Icon(
Icons.add,
color: Colors.white,
),
color: Theme.of(context).accentColor,
tooltip: 'Kullanıcı Ekle',
),
ActionButton(
onPressed: () async {
print("Tapped list all user's payments.");
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Pays(
user: widget.loggedUser,
isAdmin:
widget.loggedUser.type == 0 ? true : false,
isMe: true,
key: Key('UserPaysPage'),
)));
},
icon: const Icon(
Icons.payments,
color: Colors.white,
),
color: Color.fromRGBO(
105, 82, 181, 1), //https://www.colorhexa.com/6952b5
tooltip: 'Tüm Kullanıcı Ödemeleri',
),
ActionButton(
onPressed: () async {
print("Tapped send user's access codes.");
setState(() {
iswaiterVisible = !iswaiterVisible;
});
var allUsers = await User.getUsersFromDatabase();
if (allUsers != null && allUsers.length != 0) {
var sendDetails = new Map(); // Sended or not recording.
for (var user in allUsers) {
bool sendResult =
await InOut.sendAccessCodetoCustomer(user);
sendResult == true
? sendDetails['${Security.decryptMyData(user.email)}'] =
true
: sendDetails['${Security.decryptMyData(user.email)}'] =
false;
}
CustomSnackBar sentSnack = CustomSnackBar(
message:
'Gönderim raporu oluşturuluyor ! - Bu fonksiyon gönderimi simule eder, geçerliliği yoktur.',
type: AlertType.Info,
key: Key('ManageSnack-1'),
context: this.context);
sentSnack.showCustomSnackbar();
} else {
CustomSnackBar errSnack = CustomSnackBar(
message:
'Erişim kodları gönderilirken bir hata oluştu ! - Bu fonksiyon gönderimi simule eder, geçerliliği yoktur.',
type: AlertType.Error,
key: Key('ManageSnack-2'),
context: this.context);
errSnack.showCustomSnackbar();
}
setState(() {
iswaiterVisible = !iswaiterVisible;
});
},
icon: Icon(
Icons.send,
color: Colors.white,
),
color: Color.fromRGBO(
181, 105, 82, 1), //https://www.colorhexa.com/b56952
tooltip: 'Tüm Kullanıcılara Erişim Kodu Gönder',
),
],
......
);
As you see in my code, I use CustomSnack class, which is a customized derive of SnackBar.
//Custom message snackbar class.
class CustomSnackBar {
/// Constructor of custom snackbar. [message] is message, [type] is alert type [context] is context
CustomSnackBar(
{required Key key,
required this.message,
required this.type,
required this.context});
/// Message.
final String message;
///Alert type
final AlertType type;
final BuildContext context;
showCustomSnackbar() {
var cstsnack = SnackBar(
margin: EdgeInsets.only(left: 20, right: 20, bottom: 20),
elevation: 10,
padding: EdgeInsets.all(10),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(color: Color.fromRGBO(52, 52, 52, 0.5))),
backgroundColor: (() {
if (type == AlertType.Error) {
return Colors.red[700]!.withOpacity(0.7);
} else
return type == AlertType.Warning
? Colors.orange[700]!.withOpacity(0.7)
: Colors.green[700]!.withOpacity(0.7);
}()),
content: Row(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: Tooltip(
message: 'Alert icon.',
child: Container(
padding: EdgeInsets.all(5),
decoration: ShapeDecoration(
shape: CircleBorder(),
color: Colors.blueGrey[700],
),
child: Icon(
(() {
if (type == AlertType.Error) {
return Icons.error;
} else
return type == AlertType.Warning
? Icons.warning
: Icons.check_circle;
}()),
size: 28,
),
),
),
),
Flexible(
fit: FlexFit.tight,
child: Tooltip(
message: message,
child: Text(message,
style: (() {
return type == AlertType.Warning
? Theme.of(context).textTheme.bodyText2
: Theme.of(context)
.textTheme
.bodyText2!
.copyWith(color: Colors.white);
}())),
)),
Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: Tooltip(
message: 'Kapat',
child: IconButton(
icon: Icon(Icons.close),
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
)),
),
],
),
);
ScaffoldMessenger.of(context).showSnackBar(cstsnack);
}
}
Visualization of CustomSnackBar:
I can show this custom SnackBar everywhere with trigerring
ScaffoldMessenger.of(context).showSnackBar(cstsnack);
And it runs perfectly except the page expandable FAB is used. FAB control blocks the BuildContext in same page and custom SnackBar seems invisible. However, when I navigate the page, custom SnackBar comes from previous page state suddenly and make itself visible.
How can I fix this ?
Thanks.

