menu_kit 1.0.1
menu_kit: ^1.0.1 copied to clipboard
Animated dropdown and fan menus for Flutter with ready-to-use directional and radial presets.
import 'package:flutter/material.dart';
import 'package:menu_kit/menu_kit.dart';
void main() {
runApp(const MenuKitExampleApp());
}
class MenuKitExampleApp extends StatelessWidget {
const MenuKitExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Menu Kit',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF0F766E),
brightness: Brightness.light,
),
useMaterial3: true,
),
home: const PresetGalleryScreen(),
);
}
}
class PresetGalleryScreen extends StatelessWidget {
const PresetGalleryScreen({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Menu Kit'),
bottom: const TabBar(
tabs: [
Tab(icon: Icon(Icons.view_stream), text: 'Dropdown'),
Tab(icon: Icon(Icons.radar), text: 'Fan'),
],
),
),
body: TabBarView(
children: [
_PresetGrid(title: 'Dropdown presets', children: _dropdownCards),
_PresetGrid(title: 'Fan presets', children: _fanCards),
],
),
),
);
}
}
class _PresetGrid extends StatelessWidget {
const _PresetGrid({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 8),
sliver: SliverToBoxAdapter(
child: Text(title, style: Theme.of(context).textTheme.titleLarge),
),
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
mainAxisExtent: 240,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: children.length,
itemBuilder: (context, index) => children[index],
),
),
],
);
}
}
final List<Widget> _dropdownCards = DropDownMenuPreset.values
.map(
(preset) =>
_DropdownPresetCard(title: _enumLabel(preset.name), preset: preset),
)
.toList(growable: false);
final List<Widget> _fanCards = FanMenuPreset.values
.map(
(preset) =>
_FanPresetCard(title: _enumLabel(preset.name), preset: preset),
)
.toList(growable: false);
class _DropdownPresetCard extends StatefulWidget {
const _DropdownPresetCard({required this.title, required this.preset});
final String title;
final DropDownMenuPreset preset;
@override
State<_DropdownPresetCard> createState() => _DropdownPresetCardState();
}
class _DropdownPresetCardState extends State<_DropdownPresetCard>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _PresetCard(
title: widget.title,
child: DropDownMenu(
controller: _controller,
preset: widget.preset,
childrenMargin: const EdgeInsets.all(4),
trigger: (toggleMenu) => _TriggerButton(
heroTag: 'dropdown-${widget.preset.name}-trigger',
onPressed: toggleMenu,
),
children: _actionButtons('dropdown-${widget.preset.name}'),
),
);
}
}
class _FanPresetCard extends StatefulWidget {
const _FanPresetCard({required this.title, required this.preset});
final String title;
final FanMenuPreset preset;
@override
State<_FanPresetCard> createState() => _FanPresetCardState();
}
class _FanPresetCardState extends State<_FanPresetCard>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 420),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _PresetCard(
title: widget.title,
child: FanMenu(
controller: _controller,
preset: widget.preset,
radius: widget.preset == FanMenuPreset.circular ? 18 : 34,
childrenMargin: const EdgeInsets.all(4),
trigger: (toggleMenu) => _TriggerButton(
heroTag: 'fan-${widget.preset.name}-trigger',
onPressed: toggleMenu,
),
children: _actionButtons('fan-${widget.preset.name}'),
),
);
}
}
class _PresetCard extends StatelessWidget {
const _PresetCard({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: 0,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
color: colorScheme.surfaceContainerHighest,
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelLarge,
),
),
Expanded(
child: ColoredBox(
color: colorScheme.surface,
child: Center(child: SizedBox.expand(child: child)),
),
),
],
),
);
}
}
class _TriggerButton extends StatelessWidget {
const _TriggerButton({required this.heroTag, required this.onPressed});
final String heroTag;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return FloatingActionButton.small(
heroTag: heroTag,
onPressed: onPressed,
child: const Icon(Icons.add),
);
}
}
List<Widget> _actionButtons(String heroPrefix) {
const icons = [
Icons.home,
Icons.favorite,
Icons.camera_alt,
Icons.person,
Icons.edit,
];
return [
for (var index = 0; index < icons.length; index++)
FloatingActionButton.small(
heroTag: '$heroPrefix-action-$index',
onPressed: () {},
child: Icon(icons[index]),
),
];
}
String _enumLabel(String name) {
final buffer = StringBuffer();
for (var index = 0; index < name.length; index++) {
final char = name[index];
if (index > 0 && char.toUpperCase() == char && char.toLowerCase() != char) {
buffer.write(' ');
}
buffer.write(char);
}
final label = buffer.toString();
return label[0].toUpperCase() + label.substring(1);
}