-
-
Save SuperPenguin/218780a47c2a62c6ad4743cb3e23ece4 to your computer and use it in GitHub Desktop.
import 'dart:math'; | |
import 'package:flutter/material.dart'; | |
@immutable | |
class ExpandableFab extends StatefulWidget { | |
const ExpandableFab({ | |
super.key, | |
this.initialOpen, | |
this.distance, | |
required this.children, | |
}); | |
final bool? initialOpen; | |
final double? distance; | |
final List<ActionButton> children; | |
@override | |
ExpandableFabState createState() => ExpandableFabState(); | |
static const int animationDuration = 250; // ms | |
static const double defaultDistance = 112.0; | |
static const double closeSize = 56.0; | |
static const double closePadding = 8.0; | |
static const double elevation = 4.0; | |
static const double angleArc = 90.0; | |
} | |
/// State for [ExpandableFab] with animation data | |
class ExpandableFabState extends State<ExpandableFab> | |
with SingleTickerProviderStateMixin { | |
late final AnimationController _controller; | |
late final Animation<double> _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: ExpandableFab.animationDuration), | |
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(); | |
} | |
}); | |
} | |
Iterable<Widget> _actions() sync* { | |
final count = widget.children.length; | |
final step = ExpandableFab.angleArc / (count - 1); | |
for (var i = 0, angleInDegrees = 0.0; | |
i < count; | |
i++, angleInDegrees += step) { | |
yield _ExpandingActionButton( | |
directionInDegrees: angleInDegrees, | |
maxDistance: widget.distance ?? ExpandableFab.defaultDistance, | |
progress: _expandAnimation, | |
child: widget.children[i], | |
); | |
} | |
} | |
@override | |
Widget build(BuildContext context) { | |
return SizedBox.expand( | |
child: Stack( | |
alignment: Alignment.bottomRight, | |
clipBehavior: Clip.none, | |
children: [ | |
_FabClose(onTap: _toggle), | |
..._actions(), | |
_FabOpen(open: _open, onTap: _toggle), | |
], | |
), | |
); | |
} | |
} | |
class _FabOpen extends StatelessWidget { | |
const _FabOpen({ | |
Key? key, | |
required this.open, | |
required this.onTap, | |
}) : super(key: key); | |
final bool open; | |
final VoidCallback onTap; | |
@override | |
Widget build(BuildContext context) { | |
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: ExpandableFab.animationDuration, | |
), | |
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: ExpandableFab.animationDuration, | |
), | |
child: FloatingActionButton( | |
onPressed: onTap, | |
tooltip: "Add Categories", | |
child: const Icon(Icons.add), | |
), | |
), | |
), | |
); | |
} | |
} | |
class _FabClose extends StatelessWidget { | |
const _FabClose({ | |
Key? key, | |
required this.onTap, | |
}) : super(key: key); | |
final VoidCallback onTap; | |
@override | |
Widget build(BuildContext context) { | |
final theme = Theme.of(context); | |
return SizedBox( | |
width: ExpandableFab.closeSize, | |
height: ExpandableFab.closeSize, | |
child: Center( | |
child: Material( | |
shape: const CircleBorder(), | |
clipBehavior: Clip.antiAlias, | |
elevation: ExpandableFab.closeSize, | |
child: Tooltip( | |
message: "Close Add Categories Button", | |
child: InkWell( | |
onTap: onTap, | |
child: Padding( | |
padding: const EdgeInsets.all(ExpandableFab.closePadding), | |
child: Icon( | |
Icons.close, | |
color: theme.colorScheme.primary, | |
), | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
} | |
@immutable | |
class ActionButton extends StatelessWidget { | |
const ActionButton({ | |
super.key, | |
this.onPressed, | |
required this.icon, | |
this.tooltip, | |
this.shouldToggle = false, | |
}); | |
final VoidCallback? onPressed; | |
final Widget icon; | |
final String? tooltip; | |
final bool shouldToggle; | |
static const double elevation = 4.0; | |
@override | |
Widget build(BuildContext context) { | |
final theme = Theme.of(context); | |
return Material( | |
shape: const CircleBorder(), | |
clipBehavior: Clip.antiAlias, | |
color: theme.colorScheme.secondary, | |
elevation: elevation, | |
child: IconButton( | |
onPressed: onPressed != null | |
? () { | |
if (shouldToggle) { | |
context | |
.findAncestorStateOfType<ExpandableFabState>() | |
?._toggle(); | |
} | |
onPressed?.call(); | |
} | |
: null, | |
icon: icon, | |
tooltip: tooltip, | |
color: theme.colorScheme.onSecondary, | |
), | |
); | |
} | |
} | |
@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<double> progress; | |
final Widget child; | |
static const double baseOffset = 4.0; | |
@override | |
Widget build(BuildContext context) { | |
return AnimatedBuilder( | |
animation: progress, | |
builder: (context, child) { | |
final offset = Offset.fromDirection( | |
directionInDegrees * (pi / 180.0), | |
progress.value * maxDistance, | |
); | |
return Positioned( | |
right: baseOffset + offset.dx, | |
bottom: baseOffset + offset.dy, | |
child: Transform.rotate( | |
angle: (1.0 - progress.value) * pi / 2, | |
child: child!, | |
), | |
); | |
}, | |
child: FadeTransition( | |
opacity: progress, | |
child: child, | |
), | |
); | |
} | |
} |
Thanks so much for sharing this. After reviewing it, I went in a different direction. Notably the docs say
findAncestorStateOfType
shouldn't be used inbuild()
functions.
The docs is bit misleading in explaining why it should not be in build.
It's should not be used during the build phase, but it's okay to use it inside anonymous function/callback.
It also not adding the State as dependencies of the context, so it won't trigger rebuild like dependOnInheritedWidgetOfExactType.
Some of the .of
actually using findAncestorStateOfType
and even findRootAncestorStateOfType
, one of it is Navigator.of
.
Therefore It's common to do this
class MyWidget extends StatelessWidget {
const MyWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
Navigator.of(context).pushNamed('/a');
},
child: Text('Go to A'),
);
}
}
But not this
class MyWidget extends StatelessWidget {
const MyWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final navigator = Navigator.of(context);
return ElevatedButton(
onPressed: () {
navigator.pushNamed('/a');
},
child: Text('Go to A'),
);
}
}
Because the first example only look up navigator when pressed called, but the second will look up navigator every build
Instead I created an in-between class that holds the action button data but isn't a Widget, and then the Expandable FAB makes the widget with onPressed either having _toggle() or not. Building the widget while having access to _toggle() seemed simplest.
Imo, it's fine to have a class to compose a very specific widget inside another widget as long as you understand the cons and the quirks of it, one of example of this is the BottomNavigationBar
, which is a widget but only take the items as List<BottomNavigationBarItem>
and BottomNavigationBarItem
is not even a widget, it just a plain class. Then this class is used to compose a widget.
I probably just change how ExpandableFab open/close works, perhaps by creating a controller like class ExpandableFabController with ChangeNotifier
so that the parent can open/close the fab easily. This will make the child fab widget have access to open/close method.
Thanks so much for sharing this. After reviewing it, I went in a different direction. Notably the docs say
findAncestorStateOfType
shouldn't be used inbuild()
functions.Instead I created an in-between class that holds the action button data but isn't a
Widget
, and then the Expandable FAB makes the widget withonPressed
either having_toggle()
or not. Building the widget while having access to_toggle()
seemed simplest.