import 'dart:math' as math; import 'package:flutter/material.dart'; class ExpandableFab extends StatefulWidget { const ExpandableFab({ Key? key, this.initialOpen, required this.distance, required this.icon, required this.children, }) : super(key: key); final bool? initialOpen; final double distance; final Icon icon; final List children; @override State createState() => _ExpandableFabState(); } class _ExpandableFabState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _expandAnimation; bool _open = false; @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: 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: Theme.of(context).primaryColor, ), ), ), ), ), ); } List _buildExpandingActionButtons() { final children = []; final count = widget.children.length; final step = 60.0 / (count - 1); for (var i = 0, angleInDegrees = 15.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( onPressed: _toggle, backgroundColor: Theme.of(context).colorScheme.primary, child: widget.icon, ), ), ), ); } } @immutable class _ExpandingActionButton extends StatelessWidget { const _ExpandingActionButton({ required this.directionInDegrees, required this.maxDistance, required this.progress, required this.child, }); final double directionInDegrees; final double maxDistance; final Animation 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, ), ); } } class ActionButton extends StatelessWidget { const ActionButton({ Key? key, this.onPressed, required this.icon, }) : super(key: key); final VoidCallback? onPressed; final Widget icon; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Material( shape: const CircleBorder(), clipBehavior: Clip.antiAlias, color: theme.colorScheme.secondary, elevation: 4.0, child: IconButton( onPressed: onPressed, icon: icon, color: theme.colorScheme.onSecondary, ), ); } }