spring_bottom_sheet 1.0.2 copy "spring_bottom_sheet: ^1.0.2" to clipboard
spring_bottom_sheet: ^1.0.2 copied to clipboard

A dependency-free Flutter bottom sheet with content sizing, snap points, spring physics, coordinated scrolling, and rubber-band gestures.

example/lib/main.dart

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

void main() {
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Spring Bottom Sheet',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF2563EB),
          brightness: Brightness.light,
        ),
        fontFamily: 'Roboto',
        useMaterial3: true,
      ),
      home: const SpringBottomSheetDemo(),
    );
  }
}

class SpringBottomSheetDemo extends StatefulWidget {
  const SpringBottomSheetDemo({super.key});

  @override
  State<SpringBottomSheetDemo> createState() => _SpringBottomSheetDemoState();
}

class _SpringBottomSheetDemoState extends State<SpringBottomSheetDemo> {
  bool _animateContent = true;
  bool _autoSnapSize = true;

  Future<void> _showSheet({int initialSnapIndex = 1}) {
    return showSpringBottomSheet<void>(
      context: context,
      initialSnapIndex: _autoSnapSize ? 0 : initialSnapIndex,
      snapSizes: _autoSnapSize ? null : const [0.32, 0.62, 0.92],
      headerBuilder: (context) =>
          _SheetHeader(onClose: () => Navigator.of(context).pop()),
      builder: (context) =>
          _SheetContent(animate: _animateContent, autoSnapSize: _autoSnapSize),
    );
  }

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    return Scaffold(
      backgroundColor: const Color(0xFFF6F7FB),
      body: SafeArea(
        child: ListView(
          padding: const EdgeInsets.fromLTRB(20, 18, 20, 32),
          children: [
            Row(
              children: [
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Spring Bottom Sheet',
                        style: Theme.of(context).textTheme.headlineMedium
                            ?.copyWith(
                              color: const Color(0xFF111827),
                              fontWeight: FontWeight.w800,
                            ),
                      ),
                      const SizedBox(height: 4),
                      Text(
                        'Flutter physics + snap points',
                        style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                          color: const Color(0xFF64748B),
                        ),
                      ),
                    ],
                  ),
                ),
                IconButton.filledTonal(
                  tooltip: 'Open sheet',
                  onPressed: _showSheet,
                  icon: const Icon(Icons.keyboard_arrow_up_rounded),
                ),
              ],
            ),
            const SizedBox(height: 24),
            _OverviewStrip(
              autoSnapSize: _autoSnapSize,
              colorScheme: colorScheme,
            ),
            const SizedBox(height: 18),
            Text(
              'Interactions',
              style: Theme.of(context).textTheme.titleMedium?.copyWith(
                color: const Color(0xFF111827),
                fontWeight: FontWeight.w800,
              ),
            ),
            const SizedBox(height: 10),
            _ToggleTile(
              icon: Icons.animation_rounded,
              title: 'Animate content',
              subtitle: 'Staggered fade-slide on open',
              value: _animateContent,
              onChanged: (v) => setState(() => _animateContent = v),
            ),
            _ToggleTile(
              icon: Icons.fit_screen_rounded,
              title: 'Auto snap size',
              subtitle: 'Snap to the rendered content height',
              value: _autoSnapSize,
              onChanged: (v) => setState(() => _autoSnapSize = v),
            ),
            if (_autoSnapSize)
              _ActionTile(
                icon: Icons.fit_screen_rounded,
                title: 'Open auto',
                subtitle: 'Content-height snap',
                color: const Color(0xFF2563EB),
                onTap: _showSheet,
              )
            else ...[
              _ActionTile(
                icon: Icons.vertical_align_bottom_rounded,
                title: 'Open 32%',
                subtitle: 'Compact state',
                color: const Color(0xFF059669),
                onTap: () => _showSheet(initialSnapIndex: 0),
              ),
              _ActionTile(
                icon: Icons.unfold_more_rounded,
                title: 'Open 62%',
                subtitle: 'Balanced state',
                color: const Color(0xFF2563EB),
                onTap: () => _showSheet(initialSnapIndex: 1),
              ),
              _ActionTile(
                icon: Icons.vertical_align_top_rounded,
                title: 'Open 92%',
                subtitle: 'Expanded state',
                color: const Color(0xFF7C3AED),
                onTap: () => _showSheet(initialSnapIndex: 2),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

class _SheetHeader extends StatelessWidget {
  const _SheetHeader({required this.onClose});

  final VoidCallback onClose;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 14, 14, 12),
      child: Row(
        children: [
          Container(
            width: 42,
            height: 42,
            decoration: BoxDecoration(
              color: const Color(0xFFEFF6FF),
              borderRadius: BorderRadius.circular(12),
            ),
            child: const Icon(Icons.bolt_rounded, color: Color(0xFF2563EB)),
          ),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  'Spring controls',
                  style: Theme.of(context).textTheme.titleMedium?.copyWith(
                    color: const Color(0xFF111827),
                    fontWeight: FontWeight.w800,
                  ),
                ),
                Text(
                  'Snap, drag, release',
                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                    color: const Color(0xFF64748B),
                  ),
                ),
              ],
            ),
          ),
          IconButton(
            tooltip: 'Close sheet',
            onPressed: onClose,
            icon: const Icon(Icons.close_rounded),
          ),
        ],
      ),
    );
  }
}

class _SheetContent extends StatelessWidget {
  const _SheetContent({this.animate = true, this.autoSnapSize = false});

  final bool animate;
  final bool autoSnapSize;

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme;

    return SpringStaggeredListView(
      animate: animate,
      padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
      shrinkWrap: autoSnapSize,
      children: [
        Row(
          children: const [
            Expanded(
              child: _MetricPill(
                label: 'Tension',
                value: '210',
                color: Color(0xFF2563EB),
              ),
            ),
            SizedBox(width: 10),
            Expanded(
              child: _MetricPill(
                label: 'Damping',
                value: '20',
                color: Color(0xFF059669),
              ),
            ),
          ],
        ),
        Padding(
          padding: const EdgeInsets.only(top: 18),
          child: Text(
            'Queue',
            style: textTheme.titleMedium?.copyWith(
              color: const Color(0xFF111827),
              fontWeight: FontWeight.w800,
            ),
          ),
        ),
        const Padding(
          padding: EdgeInsets.only(top: 10),
          child: _QueueItem(
            icon: Icons.swap_vert_rounded,
            title: 'Scroll-linked resize',
            subtitle: 'Body scroll raises or lowers the sheet first',
            color: Color(0xFF2563EB),
          ),
        ),
        const _QueueItem(
          icon: Icons.compress_rounded,
          title: 'Rubber band',
          subtitle: 'Overscroll resistance near the edges',
          color: Color(0xFF059669),
        ),
        const _QueueItem(
          icon: Icons.speed_rounded,
          title: 'Velocity projection',
          subtitle: 'Release speed influences the final snap',
          color: Color(0xFFF59E0B),
        ),
        const _QueueItem(
          icon: Icons.auto_awesome_motion_rounded,
          title: 'Spring settle',
          subtitle: 'AnimationController runs SpringSimulation',
          color: Color(0xFF7C3AED),
        ),
      ],
    );
  }
}

class _OverviewStrip extends StatelessWidget {
  const _OverviewStrip({required this.autoSnapSize, required this.colorScheme});

  final bool autoSnapSize;
  final ColorScheme colorScheme;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(18),
        border: Border.all(color: const Color(0xFFE5E7EB)),
      ),
      child: Row(
        children: [
          Expanded(
            child: _OverviewMetric(
              label: 'Snaps',
              value: autoSnapSize ? '1' : '3',
              color: colorScheme.primary,
            ),
          ),
          const SizedBox(width: 12),
          Expanded(
            child: _OverviewMetric(
              label: 'Min',
              value: autoSnapSize ? 'Auto' : '32%',
              color: const Color(0xFF059669),
            ),
          ),
          const SizedBox(width: 12),
          Expanded(
            child: _OverviewMetric(
              label: 'Max',
              value: autoSnapSize ? 'Body' : '92%',
              color: const Color(0xFF7C3AED),
            ),
          ),
        ],
      ),
    );
  }
}

class _OverviewMetric extends StatelessWidget {
  const _OverviewMetric({
    required this.color,
    required this.label,
    required this.value,
  });

  final Color color;
  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          label,
          style: Theme.of(context).textTheme.labelMedium?.copyWith(
            color: const Color(0xFF64748B),
            fontWeight: FontWeight.w600,
          ),
        ),
        const SizedBox(height: 6),
        Row(
          children: [
            Container(
              width: 8,
              height: 8,
              decoration: BoxDecoration(color: color, shape: BoxShape.circle),
            ),
            const SizedBox(width: 8),
            Text(
              value,
              style: Theme.of(context).textTheme.titleLarge?.copyWith(
                color: const Color(0xFF111827),
                fontWeight: FontWeight.w800,
              ),
            ),
          ],
        ),
      ],
    );
  }
}

class _ActionTile extends StatelessWidget {
  const _ActionTile({
    required this.color,
    required this.icon,
    required this.onTap,
    required this.subtitle,
    required this.title,
  });

  final Color color;
  final IconData icon;
  final VoidCallback onTap;
  final String subtitle;
  final String title;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 10),
      child: Material(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
        child: InkWell(
          borderRadius: BorderRadius.circular(16),
          onTap: onTap,
          child: Padding(
            padding: const EdgeInsets.all(14),
            child: Row(
              children: [
                Container(
                  width: 44,
                  height: 44,
                  decoration: BoxDecoration(
                    color: Color.lerp(Colors.white, color, 0.1),
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: Icon(icon, color: color),
                ),
                const SizedBox(width: 14),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        title,
                        style: Theme.of(context).textTheme.titleMedium
                            ?.copyWith(
                              color: const Color(0xFF111827),
                              fontWeight: FontWeight.w800,
                            ),
                      ),
                      const SizedBox(height: 2),
                      Text(
                        subtitle,
                        style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                          color: const Color(0xFF64748B),
                        ),
                      ),
                    ],
                  ),
                ),
                const Icon(
                  Icons.chevron_right_rounded,
                  color: Color(0xFF94A3B8),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _ToggleTile extends StatelessWidget {
  const _ToggleTile({
    required this.icon,
    required this.onChanged,
    required this.subtitle,
    required this.title,
    required this.value,
  });

  final IconData icon;
  final ValueChanged<bool> onChanged;
  final String subtitle;
  final String title;
  final bool value;

  @override
  Widget build(BuildContext context) {
    const color = Color(0xFFF59E0B);

    return Padding(
      padding: const EdgeInsets.only(bottom: 10),
      child: Material(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
        child: Padding(
          padding: const EdgeInsets.all(14),
          child: Row(
            children: [
              Container(
                width: 44,
                height: 44,
                decoration: BoxDecoration(
                  color: Color.lerp(Colors.white, color, 0.1),
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Icon(icon, color: color),
              ),
              const SizedBox(width: 14),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      title,
                      style: Theme.of(context).textTheme.titleMedium?.copyWith(
                        color: const Color(0xFF111827),
                        fontWeight: FontWeight.w800,
                      ),
                    ),
                    const SizedBox(height: 2),
                    Text(
                      subtitle,
                      style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                        color: const Color(0xFF64748B),
                      ),
                    ),
                  ],
                ),
              ),
              Switch(value: value, onChanged: onChanged),
            ],
          ),
        ),
      ),
    );
  }
}

class _MetricPill extends StatelessWidget {
  const _MetricPill({
    required this.color,
    required this.label,
    required this.value,
  });

  final Color color;
  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(14),
      decoration: BoxDecoration(
        color: Color.lerp(Colors.white, color, 0.08),
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: Color.lerp(Colors.white, color, 0.28)!),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            label,
            style: Theme.of(context).textTheme.labelMedium?.copyWith(
              color: const Color(0xFF64748B),
              fontWeight: FontWeight.w700,
            ),
          ),
          const SizedBox(height: 4),
          Text(
            value,
            style: Theme.of(context).textTheme.headlineSmall?.copyWith(
              color: color,
              fontWeight: FontWeight.w900,
            ),
          ),
        ],
      ),
    );
  }
}

class _QueueItem extends StatelessWidget {
  const _QueueItem({
    required this.color,
    required this.icon,
    required this.subtitle,
    required this.title,
  });

  final Color color;
  final IconData icon;
  final String subtitle;
  final String title;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 10),
      child: Row(
        children: [
          Container(
            width: 42,
            height: 42,
            decoration: BoxDecoration(
              color: Color.lerp(Colors.white, color, 0.1),
              borderRadius: BorderRadius.circular(12),
            ),
            child: Icon(icon, color: color, size: 22),
          ),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  title,
                  style: Theme.of(context).textTheme.titleSmall?.copyWith(
                    color: const Color(0xFF111827),
                    fontWeight: FontWeight.w800,
                  ),
                ),
                const SizedBox(height: 2),
                Text(
                  subtitle,
                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                    color: const Color(0xFF64748B),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}
11
likes
160
points
843
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A dependency-free Flutter bottom sheet with content sizing, snap points, spring physics, coordinated scrolling, and rubber-band gestures.

Repository (GitHub)
View/report issues

Topics

#animation #ui #bottom-sheet #modal #spring

Funding

Consider supporting this project:

www.paypal.me

License

MIT (license)

Dependencies

flutter

More

Packages that depend on spring_bottom_sheet