Skip to content

Instantly share code, notes, and snippets.

@SuperPenguin
Last active June 30, 2022 04:12
Show Gist options
  • Save SuperPenguin/218780a47c2a62c6ad4743cb3e23ece4 to your computer and use it in GitHub Desktop.
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,
),
);
}
}
@EnduringBeta
Copy link

EnduringBeta commented Jun 29, 2022

Thanks so much for sharing this. After reviewing it, I went in a different direction. Notably the docs say findAncestorStateOfType shouldn't be used in build() 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 with onPressed either having _toggle() or not. Building the widget while having access to _toggle() seemed simplest.

@SuperPenguin
Copy link
Author

SuperPenguin commented Jun 30, 2022

Thanks so much for sharing this. After reviewing it, I went in a different direction. Notably the docs say findAncestorStateOfType shouldn't be used in build() 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment