menu_kit 1.0.1 copy "menu_kit: ^1.0.1" to clipboard
menu_kit: ^1.0.1 copied to clipboard

Animated dropdown and fan menus for Flutter with ready-to-use directional and radial presets.

example/lib/main.dart

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);
}
0
likes
160
points
6
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Animated dropdown and fan menus for Flutter with ready-to-use directional and radial presets.

Homepage
Repository (GitHub)
View/report issues

Topics

#menu #dropdown #animation #radial-menu #flutter

License

MIT (license)

Dependencies

flutter

More

Packages that depend on menu_kit