If you ever wondered how a FAB can open up to a menu, here is an implementation I've recently used.
The way we achieve this is by creating a widget which we will use inside a Scaffold's floatingActionButton, we'll call it FloatingActionButtonMenu, and it will transform between a closed state, where only one FAB is presented, and an open state, where a set of menu buttons appear.
I'll break down my approach to getting this animation to several steps, so feel free to follow along or jump to the last part (Step 4)
Step 1
Forget about animation, just start out with the state change. Sometimes it is easier to build the animation incrementally, and the first step would be defining the start and end states.
In this case we use a ChangeNotifier to represent our state. We will then provide it using the provider package. This way, whenever the menu is opened/closed, FloatingActionButtonMenu will rebuild. I assume most developers will be already familiar with this approach, but let me know if I should expand on that.
Note: Add a hero tag to all FloatingActionButton, because otherwise the new page that contains a scaffold will throw an error, I assume this is because scaffolds animate the FAB between each other.
import 'package:fab_menu/page.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class FloatingActionButtonMenu extends StatelessWidget {
const FloatingActionButtonMenu({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MenuState(),
builder: (context, child) {
final menuState = context.watch<MenuState>();
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
_OptionButton(label: 'Option 1', visible: menuState.isOpen),
const SizedBox(height: 16),
_OptionButton(label: 'Option 2', visible: menuState.isOpen),
const SizedBox(height: 16),
_OptionButton(label: 'Option 3', visible: menuState.isOpen),
const SizedBox(height: 16),
FloatingActionButton(
heroTag: 'menu_button',
key: const ValueKey('menu_button'),
onPressed: () {
if (menuState.isOpen) {
menuState.close();
} else {
menuState.open();
}
},
child: const Icon(Icons.add),
),
],
);
},
);
}
}
class MenuState extends ChangeNotifier {
bool _isOpen = false;
bool get isOpen => _isOpen;
void toggle() {
if (_isOpen) {
close();
} else {
open();
}
}
void open() {
_isOpen = true;
notifyListeners();
}
void close() {
_isOpen = false;
notifyListeners();
}
}
class _OptionButton extends StatelessWidget {
const _OptionButton({required this.label, required this.visible});
final String label;
final bool visible;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Visibility(
visible: visible,
child: FloatingActionButton.extended(
heroTag: label,
backgroundColor: colorScheme.secondaryContainer,
label: Text(label),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DummyPage(label: label),
),
);
},
),
);
}
}
Step 2
Add scale animation, animating all buttons at once. Because we are using flutter_animate package, we basically can transform any widget to an implicit animation widget. Implicit, in the sense that changing the target value will animate the widget. So if we animate all the menu buttons to scale between 0.0 and 1.0 when the menu state changes, we will have our FAB animate between the states.
Here is what it would look like if we just added the animation.
Visibility(
visible: visible,
child: FloatingActionButton.extended(
heroTag: label,
backgroundColor: colorScheme.secondaryContainer,
label: Text(label),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DummyPage(label: label),
),
);
},
),
).animate(target: visible ? 1.0 : 0.0).scaleXY(begin: 0, end: 1);
You’ll notice that the animation works when you open the menu but doesn’t in the opposite direction. The reason is that once you close the menu, the state changes and that triggers the visibility to hide the menu buttons. We can remove the Visibility widget and rely on the scaling of the widgets, so that when they are scaled to 0 it is equivalent to them being invisible.
import 'package:fab_menu/page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
class FloatingActionButtonMenu extends StatelessWidget {
const FloatingActionButtonMenu({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MenuState(),
builder: (context, child) {
final menuState = context.watch<MenuState>();
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
_OptionButton(label: 'Option 1', visible: menuState.isOpen),
const SizedBox(height: 16),
_OptionButton(label: 'Option 2', visible: menuState.isOpen),
const SizedBox(height: 16),
_OptionButton(label: 'Option 3', visible: menuState.isOpen),
const SizedBox(height: 16),
FloatingActionButton(
heroTag: 'menu_button',
key: const ValueKey('menu_button'),
onPressed: () {
if (menuState.isOpen) {
menuState.close();
} else {
menuState.open();
}
},
child: const Icon(Icons.add),
),
],
);
},
);
}
}
class MenuState extends ChangeNotifier {
bool _isOpen = false;
bool get isOpen => _isOpen;
void toggle() {
if (_isOpen) {
close();
} else {
open();
}
}
void open() {
_isOpen = true;
notifyListeners();
}
void close() {
_isOpen = false;
notifyListeners();
}
}
class _OptionButton extends StatelessWidget {
const _OptionButton({required this.label, required this.visible});
final String label;
final bool visible;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return FloatingActionButton.extended(
heroTag: label,
backgroundColor: colorScheme.secondaryContainer,
label: Text(label),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DummyPage(label: label),
),
);
},
).animate(target: visible ? 1.0 : 0.0).scaleXY(begin: 0, end: 1);
}
}
Step 3
Now we want to add some fluidity to the animation by staggering the scaling of the option buttons so that they scale one after the other. If we weren’t using the flutter_animate package we would have to write some extra boiler plate code, probably using an interval curve to introduce a delay. In our case we just add a delay parameter and we’re done.
import 'package:fab_menu/page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
class FloatingActionButtonMenu extends StatelessWidget {
const FloatingActionButtonMenu({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MenuState(),
builder: (context, child) {
final menuState = context.watch<MenuState>();
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
_OptionButton(label: 'Option 1', visible: menuState.isOpen),
const SizedBox(height: 16),
_OptionButton(
label: 'Option 2',
visible: menuState.isOpen,
delay: const Duration(milliseconds: 100),
),
const SizedBox(height: 16),
_OptionButton(
label: 'Option 3',
visible: menuState.isOpen,
delay: const Duration(milliseconds: 200)),
const SizedBox(height: 16),
FloatingActionButton(
heroTag: 'menu_button',
key: const ValueKey('menu_button'),
onPressed: () {
if (menuState.isOpen) {
menuState.close();
} else {
menuState.open();
}
},
child: const Icon(Icons.add),
),
],
);
},
);
}
}
class MenuState extends ChangeNotifier {
bool _isOpen = false;
bool get isOpen => _isOpen;
void toggle() {
if (_isOpen) {
close();
} else {
open();
}
}
void open() {
_isOpen = true;
notifyListeners();
}
void close() {
_isOpen = false;
notifyListeners();
}
}
class _OptionButton extends StatelessWidget {
const _OptionButton(
{required this.label, required this.visible, this.delay = Duration.zero});
final String label;
final bool visible;
final Duration delay;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return FloatingActionButton.extended(
heroTag: label,
backgroundColor: colorScheme.secondaryContainer,
label: Text(label),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DummyPage(label: label),
),
);
},
)
.animate(target: visible ? 1.0 : 0.0)
.scaleXY(begin: 0, end: 1, delay: delay);
}
}
Step 4
Now all that remains would be to animate the icon of the FAB between what it shows when the menu is closed and an X icon for when the menu is open. For that we’ll use the AnimatedCrossFade widget, just because it exists, but obviously we could have achieved the exact same result by making our own cross fading widget with the flutter_animate package. Here is the final result, let me know if you would have done something differently.
import 'package:fab_menu/page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
class FloatingActionButtonMenu extends StatelessWidget {
const FloatingActionButtonMenu({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MenuState(),
builder: (context, child) {
final menuState = context.watch<MenuState>();
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
_OptionButton(label: 'Option 1', visible: menuState.isOpen),
const SizedBox(height: 16),
_OptionButton(
label: 'Option 2',
visible: menuState.isOpen,
delay: const Duration(milliseconds: 100),
),
const SizedBox(height: 16),
_OptionButton(
label: 'Option 3',
visible: menuState.isOpen,
delay: const Duration(milliseconds: 200)),
const SizedBox(height: 16),
FloatingActionButton(
heroTag: 'menu_button',
key: const ValueKey('menu_button'),
onPressed: () {
if (menuState.isOpen) {
menuState.close();
} else {
menuState.open();
}
},
child: AnimatedCrossFade(
firstChild: const Icon(Icons.shopping_bag_outlined),
secondChild: const Icon(Icons.close),
crossFadeState: menuState.isOpen
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
),
],
);
},
);
}
}
class MenuState extends ChangeNotifier {
bool _isOpen = false;
bool get isOpen => _isOpen;
void toggle() {
if (_isOpen) {
close();
} else {
open();
}
}
void open() {
_isOpen = true;
notifyListeners();
}
void close() {
_isOpen = false;
notifyListeners();
}
}
class _OptionButton extends StatelessWidget {
const _OptionButton(
{required this.label, required this.visible, this.delay = Duration.zero});
final String label;
final bool visible;
final Duration delay;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return FloatingActionButton.extended(
heroTag: label,
backgroundColor: colorScheme.secondaryContainer,
label: Text(label),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DummyPage(label: label),
),
);
},
)
.animate(target: visible ? 1.0 : 0.0)
.scaleXY(begin: 0, end: 1, delay: delay);
}
}
Supporting files
main.dart
import 'package:fab_menu/floating_action_button_menu.dart';
import 'package:flutter/material.dart';
final colorScheme = ColorScheme.fromSeed(seedColor: Colors.lightGreenAccent);
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: colorScheme,
useMaterial3: true,
),
home: Scaffold(
floatingActionButton: const FloatingActionButtonMenu(),
body: SizedBox.expand(
child: Padding(
padding: const EdgeInsets.all(16),
child: FittedBox(
fit: BoxFit.contain,
child: Text(
'Built with Flutter!',
style: TextStyle(
color: colorScheme.primary,
),
),
),
),
),
),
);
}
}
page.dart
import 'package:flutter/material.dart';
class DummyPage extends StatelessWidget {
const DummyPage({super.key, required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text(label),
),
);
}
}
Comentários