Last active
June 30, 2022 04:12
-
-
Save SuperPenguin/218780a47c2a62c6ad4743cb3e23ece4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
), | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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 usingfindAncestorStateOfType
and evenfindRootAncestorStateOfType
, one of it isNavigator.of
.Therefore It's common to do this
But not this
Because the first example only look up navigator when pressed called, but the second will look up navigator every build
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 asList<BottomNavigationBarItem>
andBottomNavigationBarItem
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.