smooth_dropdown 1.0.0 copy "smooth_dropdown: ^1.0.0" to clipboard
smooth_dropdown: ^1.0.0 copied to clipboard

Smooth-motion dropdown widgets for Flutter: an expansion tile that reveals any content and a typed select. All open and close motion runs on the render layer.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:smooth_dropdown/smooth_dropdown.dart';

void main() => runApp(const SmoothGalleryApp());

/// The demo app for the smooth_dropdown package.
class SmoothGalleryApp extends StatelessWidget {
  /// Makes the demo app.
  const SmoothGalleryApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Smooth Dropdown',
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(
        useMaterial3: true,
      ).copyWith(scaffoldBackgroundColor: const Color(0xFF05050A)),
      home: const _GalleryPage(),
    );
  }
}

/// A custom color set that ships with no preset, to show full color control.
const SmoothPalette _sunset = SmoothPalette(
  accent: Color(0xFFFDBA74),
  accentBright: Color(0xFFFFEDD5),
  accentDeep: Color(0xFFF97316),
  fillTop: Color(0xFF2A1A10),
  fillBottom: Color(0xFF130A05),
);

class _GalleryPage extends StatefulWidget {
  const _GalleryPage();

  @override
  State<_GalleryPage> createState() => _GalleryPageState();
}

class _GalleryPageState extends State<_GalleryPage>
    with SingleTickerProviderStateMixin {
  static const int _sectionCount = 8;

  late final AnimationController _entrance;
  late final List<Animation<double>> _reveals;
  final SmoothExpansionController _tasksTile = SmoothExpansionController();

  String? _fruit;
  String _plan = 'free';
  String _voice = 'aria';
  bool _reduceMotion = false;

  @override
  void initState() {
    super.initState();
    _entrance = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 1150),
    );
    _reveals = List<Animation<double>>.generate(_sectionCount, (index) {
      final start = (0.07 * index).clamp(0.0, 0.55);
      return CurvedAnimation(
        parent: _entrance,
        curve: Interval(
          start,
          (start + 0.5).clamp(0.0, 1.0),
          curve: Curves.easeOutCubic,
        ),
      );
    });
    unawaited(_entrance.forward());
  }

  @override
  void dispose() {
    for (final reveal in _reveals) {
      if (reveal is CurvedAnimation) reveal.dispose();
    }
    _entrance.dispose();
    _tasksTile.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    Widget gallery = ListView(
      padding: const EdgeInsets.fromLTRB(20, 8, 20, 56),
      children: <Widget>[
        _hero(),
        _sectionHeader(
          'Pick one — themed selects',
          'Three palettes. Colors, icons, and the sheen are all replaceable.',
          _reveals[0],
        ),
        _fruitSelect(),
        const SizedBox(height: 14),
        _planSelect(),
        const SizedBox(height: 14),
        _voiceSelect(),
        _sectionHeader(
          'One custom style',
          'A hand-built palette, bigger radius, taller wave, springy arrow.',
          _reveals[3],
        ),
        _customStyleTile(),
        _sectionHeader(
          'FAQ — text that rises as it opens',
          'The short SmoothExpansionTile.text factory.',
          _reveals[4],
        ),
        _faqTile(
          'What is it?',
          'A set of widgets. The open and close move is a wavy '
              'clip, a soft glow, a light sheen, and a small squash. It all '
              'runs on the render layer.',
          Icons.water_drop_rounded,
          _reveals[4],
          'faq-what',
        ),
        const SizedBox(height: 12),
        _faqTile(
          'Can it hold any widget?',
          'Yes. A tile can show any content. This text tile is just one easy '
              'way to use it.',
          Icons.auto_awesome_rounded,
          _reveals[5],
          'faq-any',
        ),
        _sectionHeader(
          'Any content + your own control',
          'Chips, a banner, and buttons. Open and close it from outside too.',
          _reveals[6],
        ),
        _controlRow(),
        const SizedBox(height: 10),
        _anyContentTile(),
        _sectionHeader(
          'Your own header and arrow',
          'A full custom header with a plus that morphs on open.',
          _reveals[7],
        ),
        _customHeaderTile(),
      ],
    );

    if (_reduceMotion) {
      gallery = MediaQuery(
        data: MediaQuery.of(context).copyWith(disableAnimations: true),
        child: gallery,
      );
    }

    return Scaffold(
      body: Stack(
        children: <Widget>[
          const Positioned.fill(child: _GlowBackdrop()),
          SafeArea(child: gallery),
        ],
      ),
    );
  }

  Widget _hero() {
    return Padding(
      padding: const EdgeInsets.only(top: 24, bottom: 8),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                const Text(
                  'Smooth Dropdown',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 32,
                    height: 1.05,
                    fontWeight: FontWeight.w800,
                    letterSpacing: -0.5,
                  ),
                ),
                const SizedBox(height: 6),
                Text(
                  'Selects and tiles that open with a smooth move.',
                  style: TextStyle(
                    color: Colors.white.withValues(alpha: 0.55),
                    fontSize: 14,
                  ),
                ),
              ],
            ),
          ),
          _reduceMotionToggle(),
        ],
      ),
    );
  }

  Widget _reduceMotionToggle() {
    return Column(
      children: <Widget>[
        Icon(
          _reduceMotion
              ? Icons.motion_photos_off_rounded
              : Icons.motion_photos_on_rounded,
          size: 20,
          color: Colors.white.withValues(alpha: 0.6),
        ),
        Transform.scale(
          scale: 0.8,
          child: Switch(
            value: _reduceMotion,
            activeThumbColor: const Color(0xFF7DD3FC),
            onChanged: (value) => setState(() => _reduceMotion = value),
          ),
        ),
      ],
    );
  }

  Widget _fruitSelect() {
    return SmoothSelect<String>(
      value: _fruit,
      entrance: _reveals[0],
      hint: const Text('Pick a fruit'),
      leading: const Icon(Icons.eco_rounded),
      onChanged: (value) => setState(() => _fruit = value),
      items: const <SmoothSelectItem<String>>[
        SmoothSelectItem<String>(
          value: 'apple',
          leading: Icon(Icons.circle, size: 12, color: Color(0xFFEF4444)),
          child: Text('Apple'),
        ),
        SmoothSelectItem<String>(
          value: 'lime',
          leading: Icon(Icons.circle, size: 12, color: Color(0xFF84CC16)),
          child: Text('Lime'),
        ),
        SmoothSelectItem<String>(
          value: 'plum',
          leading: Icon(Icons.circle, size: 12, color: Color(0xFF8B5CF6)),
          child: Text('Plum'),
        ),
        SmoothSelectItem<String>(
          value: 'gone',
          enabled: false,
          leading: Icon(Icons.circle, size: 12, color: Color(0xFF52525B)),
          child: Text('Sold out'),
        ),
      ],
    );
  }

  Widget _planSelect() {
    return SmoothTheme(
      data: const SmoothStyle(palette: SmoothPalette.orchid),
      child: SmoothSelect<String>(
        value: _plan,
        entrance: _reveals[1],
        leading: const Icon(Icons.workspace_premium_rounded),
        onChanged: (value) => setState(() => _plan = value ?? 'free'),
        items: const <SmoothSelectItem<String>>[
          SmoothSelectItem<String>(value: 'free', child: Text('Free')),
          SmoothSelectItem<String>(value: 'pro', child: Text('Pro')),
          SmoothSelectItem<String>(value: 'team', child: Text('Team')),
        ],
      ),
    );
  }

  Widget _voiceSelect() {
    return SmoothTheme(
      data: const SmoothStyle(palette: SmoothPalette.mint),
      child: SmoothSelect<String>(
        value: _voice,
        entrance: _reveals[2],
        leading: const Icon(Icons.graphic_eq_rounded),
        onChanged: (value) => setState(() => _voice = value ?? 'aria'),
        selectedItemBuilder: (context, item) => Row(
          children: <Widget>[
            const Text('Voice: ', style: TextStyle(color: Color(0xFF6EE7B7))),
            DefaultTextStyle.merge(
              style: const TextStyle(fontWeight: FontWeight.w700),
              child: item.child,
            ),
          ],
        ),
        items: const <SmoothSelectItem<String>>[
          SmoothSelectItem<String>(value: 'aria', child: Text('Aria')),
          SmoothSelectItem<String>(value: 'cove', child: Text('Cove')),
          SmoothSelectItem<String>(value: 'sol', child: Text('Sol')),
        ],
      ),
    );
  }

  Widget _customStyleTile() {
    const style = SmoothStyle(
      palette: _sunset,
      radius: 26,
      waveAmplitude: 16,
      waveSegments: 72,
      motion: SmoothMotionSpec(
        expandDuration: Duration(milliseconds: 720),
        collapseDuration: Duration(milliseconds: 620),
        expandCurve: Curves.easeOutQuint,
        chevronCurve: Curves.elasticOut,
      ),
    );
    return SmoothExpansionTile(
      style: style,
      entrance: _reveals[3],
      leading: const Icon(Icons.local_fire_department_rounded),
      title: const Text('Sunset preset'),
      subtitle: const Text('Every knob is set by hand'),
      child: Text(
        'This card uses a custom palette with no preset, a wider corner '
        'radius, a taller bottom wave, and a springy arrow curve. None of it '
        'is hardcoded in the widget — it all comes from SmoothStyle.',
        style: TextStyle(
          color: Colors.white.withValues(alpha: 0.75),
          height: 1.4,
        ),
      ),
    );
  }

  Widget _faqTile(
    String title,
    String text,
    IconData icon,
    Animation<double> entrance,
    Object seed,
  ) {
    return SmoothExpansionTile.text(
      title: title,
      text: text,
      icon: icon,
      entrance: entrance,
      phaseSeed: seed,
    );
  }

  Widget _controlRow() {
    return _Reveal(
      animation: _reveals[6],
      child: ListenableBuilder(
        listenable: _tasksTile,
        builder: (context, _) {
          final open = _tasksTile.isExpanded;
          final icon = open
              ? Icons.unfold_less_rounded
              : Icons.unfold_more_rounded;
          return Row(
            children: <Widget>[
              _ghostButton(
                icon: icon,
                label: open ? 'Collapse' : 'Expand',
                onTap: _tasksTile.toggle,
              ),
              const SizedBox(width: 10),
              Text(
                open ? 'Open' : 'Closed',
                style: TextStyle(
                  color: Colors.white.withValues(alpha: 0.4),
                  fontSize: 12,
                ),
              ),
            ],
          );
        },
      ),
    );
  }

  Widget _anyContentTile() {
    return SmoothExpansionTile(
      controller: _tasksTile,
      entrance: _reveals[6],
      leading: const Icon(Icons.dashboard_customize_rounded),
      title: const Text('Custom content'),
      subtitle: const Text('Banner, chips, and a button'),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Container(
            height: 68,
            width: double.infinity,
            alignment: Alignment.centerLeft,
            padding: const EdgeInsets.symmetric(horizontal: 16),
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(14),
              gradient: const LinearGradient(
                colors: <Color>[Color(0xFF38BDF8), Color(0xFF818CF8)],
              ),
            ),
            child: const Text(
              'Any widget fits inside',
              style: TextStyle(
                color: Colors.white,
                fontSize: 16,
                fontWeight: FontWeight.w700,
              ),
            ),
          ),
          const SizedBox(height: 12),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: <Widget>[
              for (final label in const <String>['Design', 'Build', 'Ship'])
                Chip(label: Text(label)),
            ],
          ),
          const SizedBox(height: 12),
          FilledButton.icon(
            onPressed: _tasksTile.collapse,
            icon: const Icon(Icons.check_rounded, size: 18),
            label: const Text('Close from inside'),
          ),
        ],
      ),
    );
  }

  Widget _customHeaderTile() {
    return SmoothExpansionTile(
      entrance: _reveals[7],
      style: const SmoothStyle(palette: SmoothPalette.mint),
      headerBuilder: (context, expand, controller) => GestureDetector(
        onTap: controller.toggle,
        behavior: HitTestBehavior.opaque,
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            children: <Widget>[
              const _Avatar(),
              const SizedBox(width: 14),
              const Expanded(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      'Ava Reeves',
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 15,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                    SizedBox(height: 2),
                    Text(
                      'Product designer',
                      style: TextStyle(
                        color: Color(0x996EE7B7),
                        fontSize: 12.5,
                      ),
                    ),
                  ],
                ),
              ),
              _MorphPlus(expand: expand),
            ],
          ),
        ),
      ),
      child: Text(
        'The whole header is yours. This one has an avatar, two lines of '
        'text, and a plus mark that turns into a cross as the tile opens. '
        'It is all driven by the open value the builder gives you.',
        style: TextStyle(
          color: Colors.white.withValues(alpha: 0.75),
          height: 1.4,
        ),
      ),
    );
  }

  Widget _ghostButton({
    required IconData icon,
    required String label,
    required VoidCallback onTap,
  }) {
    return GestureDetector(
      onTap: onTap,
      behavior: HitTestBehavior.opaque,
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(999),
          border: Border.all(color: Colors.white.withValues(alpha: 0.16)),
          color: Colors.white.withValues(alpha: 0.04),
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Icon(icon, size: 16, color: Colors.white.withValues(alpha: 0.8)),
            const SizedBox(width: 7),
            Text(
              label,
              style: TextStyle(
                color: Colors.white.withValues(alpha: 0.85),
                fontSize: 13,
                fontWeight: FontWeight.w600,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _sectionHeader(
    String title,
    String caption,
    Animation<double> animation,
  ) {
    return _Reveal(
      animation: animation,
      child: Padding(
        padding: const EdgeInsets.only(top: 30, bottom: 14),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              title.toUpperCase(),
              style: TextStyle(
                color: Colors.white.withValues(alpha: 0.55),
                fontSize: 12,
                letterSpacing: 1.4,
                fontWeight: FontWeight.w700,
              ),
            ),
            const SizedBox(height: 4),
            Text(
              caption,
              style: TextStyle(
                color: Colors.white.withValues(alpha: 0.3),
                fontSize: 12.5,
                height: 1.3,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Fades and slides a widget up, driven by one shared entrance value.
class _Reveal extends StatelessWidget {
  const _Reveal({required this.animation, required this.child});

  final Animation<double> animation;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: animation,
      child: SlideTransition(
        position: Tween<Offset>(
          begin: const Offset(0, 0.1),
          end: Offset.zero,
        ).animate(animation),
        child: child,
      ),
    );
  }
}

/// A plus mark that turns forty-five degrees into a cross as the tile opens.
class _MorphPlus extends StatelessWidget {
  const _MorphPlus({required this.expand});

  final Animation<double> expand;

  @override
  Widget build(BuildContext context) {
    return RotationTransition(
      turns: Tween<double>(begin: 0, end: 0.125).animate(expand),
      child: Container(
        padding: const EdgeInsets.all(5),
        decoration: const BoxDecoration(
          shape: BoxShape.circle,
          color: Color(0x226EE7B7),
        ),
        child: const Icon(
          Icons.add_rounded,
          size: 18,
          color: Color(0xFF6EE7B7),
        ),
      ),
    );
  }
}

/// A round gradient avatar with initials.
class _Avatar extends StatelessWidget {
  const _Avatar();

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 44,
      height: 44,
      alignment: Alignment.center,
      decoration: const BoxDecoration(
        shape: BoxShape.circle,
        gradient: LinearGradient(
          colors: <Color>[Color(0xFF6EE7B7), Color(0xFF10B981)],
        ),
      ),
      child: const Text(
        'AR',
        style: TextStyle(
          color: Color(0xFF07130D),
          fontSize: 15,
          fontWeight: FontWeight.w800,
        ),
      ),
    );
  }
}

/// The soft ambient background behind the gallery.
class _GlowBackdrop extends StatelessWidget {
  const _GlowBackdrop();

  @override
  Widget build(BuildContext context) {
    return const IgnorePointer(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: <Color>[Color(0xFF0C0C18), Color(0xFF05050A)],
          ),
        ),
        child: Stack(
          children: <Widget>[
            Positioned(
              top: -130,
              left: -90,
              child: _Blob(color: Color(0x338B5CF6), size: 340),
            ),
            Positioned(
              bottom: -160,
              right: -110,
              child: _Blob(color: Color(0x2238BDF8), size: 380),
            ),
          ],
        ),
      ),
    );
  }
}

/// One soft, fading color circle.
class _Blob extends StatelessWidget {
  const _Blob({required this.color, required this.size});

  final Color color;
  final double size;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: size,
      height: size,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        gradient: RadialGradient(
          colors: <Color>[color, color.withValues(alpha: 0)],
        ),
      ),
    );
  }
}
4
likes
160
points
--
downloads

Documentation

API reference

Publisher

unverified uploader

Smooth-motion dropdown widgets for Flutter: an expansion tile that reveals any content and a typed select. All open and close motion runs on the render layer.

Repository (GitHub)
View/report issues

Topics

#dropdown #animation #motion #ui #widget

License

MIT (license)

Dependencies

flutter

More

Packages that depend on smooth_dropdown