한국어 문서 보기 (View in Korean)

grabber_sheet

pub.dev Test codecov license

Table of Contents


A reusable and customizable draggable bottom sheet with a grabber handle, inspired by the modal sheets in popular apps like Google Maps.

It solves the common frustrations with Flutter's built-in DraggableScrollableSheet:

  1. Independent Grabber Area: The grabber and header are separate from the scrollable content. Dragging the header moves the sheet; scrolling the content scrolls the list. No more gesture conflicts.
  2. Predictable Snapping: Easily snap to specific heights without complex controller logic.
  3. Seamless Integration: Works perfectly with ListView, SingleChildScrollView, and other scrollable widgets.

Why use GrabberSheet?

If you've ever struggled with:

  • The grabber disappearing when you scroll down.
  • The sheet not moving when you try to drag the header.
  • Janky snapping animations.

Then GrabberSheet is the solution you've been looking for. It provides a robust, production-ready implementation of the "modal bottom sheet" pattern found in top-tier mobile apps.

Features

  • Draggable bottom sheet with a customizable grabber handle.
  • Stable and predictable behavior, fixing common scroll controller conflicts.
  • Use any widget as the content of the sheet via a builder.
  • Insert a custom widget into the draggable grabber area.
  • Customize sheet sizes (initial, min, max).
  • Optional snapping behavior with custom snap points via snap and snapSizes.
  • Customize grabber style (color, size, shape).
  • Grabber is automatically hidden on desktop and web platforms for a native feel.
  • Sheet has rounded top corners by default (customizable via borderRadius).
  • Programmatic Control: Use GrabberSheetController to maximize(), minimize(), or animateTo() specific sizes.
  • State Callbacks: Receive notifications on onSizeChanged during dragging/resizing and onSnap when the sheet settles at a snap point.

Getting started

Add this to your package's pubspec.yaml file. Check the latest version on pub.dev.

dependencies:
  grabber_sheet: ^1.2.0

Then, install it by running flutter pub get in your terminal.

Compatibility

This package is compatible with the following SDK versions:

  • Flutter: >=3.0.0
  • Dart: >=3.0.0 <4.0.0

Basic Usage

Here's a simple example of how to use GrabberSheet:

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

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

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final sheetColor = Colors.blue.shade100;

    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        title: const Text('GrabberSheet Example'),
        backgroundColor: sheetColor,
      ),
      body: Stack(
        children: [
          Center(
            child: Text(
              'Background Content',
              style: theme.textTheme.headlineMedium,
            ),
          ),
          GrabberSheet(
            initialChildSize: 0.5,
            minChildSize: 0.2,
            maxChildSize: 0.8,
            snap: true,
            snapSizes: const [.5],
            backgroundColor: sheetColor,
            borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), // Custom border radius
            grabberStyle: GrabberStyle(color: Colors.grey.shade400),
            bottom: Row(
              children: [
                const Text('sheet title'),
                const Spacer(),
                IconButton(onPressed: () {}, icon: const Icon(Icons.close)),
              ],
            ),
            bottomAreaPadding: const EdgeInsets.symmetric(horizontal: 16),
            builder: (BuildContext context, ScrollController scrollController) {
              return ListView.builder(
                controller: scrollController,
                itemCount: 30,
                itemBuilder: (BuildContext context, int index) {
                  return ListTile(
                    title: Text(
                      'Item $index',
                      style: TextStyle(
                        color: theme.colorScheme.onSurface,
                      ),
                    ),
                  );
                },
              );
            },
          ),
        ],
      ),
    );
  }
}

Advanced Customization

Controlling Snap Behavior

By setting snap to true, the sheet will automatically animate to the nearest defined snap point when a drag gesture ends. This creates a clean, predictable motion for the user.

The snapping logic behaves differently based on the drag gesture:

  • Slow Drag: If the user slowly drags the sheet and releases, it will snap to the closest snap point.
  • Fling: If the user "flings" the sheet with a quick gesture, it will animate to the nearest boundary (either minChildSize or maxChildSize) in the direction of the fling.

You can define the snap points using minChildSize, maxChildSize, and the optional snapSizes for any intermediate points.

Here is an example with multiple intermediate snap points:

GrabberSheet(
  // Enable snapping
  snap: true, 
  
  // Define snap points: 0.2 (min), 0.5, 0.8 (intermediate), and 1.0 (max)
  minChildSize: 0.2,
  maxChildSize: 1.0,
  initialChildSize: 0.5,
  snapSizes: const [0.5, 0.8],
  
  builder: (context, scrollController) {
    // ... your content
  },
),

snap gif

Customizing the Grabber

The appearance of the grabber handle can be fully customized using the grabberStyle property.

GrabberSheet(
  grabberStyle: GrabberStyle(
    width: 60,
    height: 6,
    margin: const EdgeInsets.symmetric(vertical: 10),
    color: Colors.grey.shade300,
    radius: const Radius.circular(12),
  ),
  builder: (context, scrollController) {
    // ... your content
  },
),

You can also hide the grabber completely by setting showGrabber: false.

Adding a Custom Widget to the Grabber Area

You can insert a custom widget into the draggable area below the grabber handle using the bottom property. This entire area (handle and custom widget) is draggable. This is useful for adding a title, action buttons, or any other information that should remain visible and separate from the scrollable content.

The bottomAreaPadding property can be used to add padding around this custom widget.

GrabberSheet(
  bottom: Row(
    children: [
      const Text('sheet title'),
      const Spacer(),
      IconButton(onPressed: () {}, icon: const Icon(Icons.close)),
    ],
  ),
  bottomAreaPadding: const EdgeInsets.symmetric(horizontal: 16),
  builder: (context, scrollController) {
    // ... your list of locations
  },
),

Programmatic Control & State Listeners

You can use GrabberSheetController to control the sheet's position programmatically and listen for state changes to update the UI.

Key Methods:

  • maximize(): Smoothly animates the sheet to its maxChildSize.
  • minimize(): Smoothly animates the sheet to its minChildSize.
  • animateTo(double size): Animates the sheet to a specific size (fraction).

State Callbacks:

  • onSizeChanged: Triggered whenever the sheet's size changes (during dragging or animation). Useful for responsive UI updates based on drag progress.
  • onSnap: Triggered when the sheet settles at a specific snap point.

The complete implementation can be found in example/lib/main.dart.

class _ExampleHomePageState extends State<ExampleHomePage> {
  final GrabberSheetController _grabberSheetController = GrabberSheetController();
  String _currentSheetStatus = 'Idle';
  double _currentSize = 0.5;

  @override
  void initState() {
    super.initState();
    // 2. Listen to state changes (optional, for controller-based updates)
    _grabberSheetController.addListener(() {
      if (_grabberSheetController.isAttached && mounted) {
        setState(() {
          _currentSize = _grabberSheetController.size;
        });
      }
    });
  }
  ...

  @override
  Widget build(BuildContext context) {
    ...
    return Scaffold(
      ...
      floatingActionButton: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          FloatingActionButton.small(
            heroTag: 'maximize',
            // Control: Maximize the sheet
            onPressed: () => _grabberSheetController.maximize(),
            tooltip: 'Maximize',
            child: const Icon(Icons.keyboard_arrow_up),
          ),
          ...
          FloatingActionButton.small(
            heroTag: 'minimize',
            // Control: Minimize the sheet
            onPressed: () => _grabberSheetController.minimize(),
            tooltip: 'Minimize',
            child: const Icon(Icons.keyboard_arrow_down),
          ),
          ...
          FloatingActionButton.small(
            heroTag: 'animate',
            // Control: Animate to a specific size (0.7)
            onPressed: () => _grabberSheetController.animateTo(
              0.7,
              duration: const Duration(milliseconds: 300),
              curve: Curves.easeInOut,
            ),
            tooltip: 'Animate to 0.7',
            child: const Icon(Icons.height),
          ),
        ],
      ),
      body: Stack(
        children: [
          Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  'Background Content',
                  style: theme.textTheme.headlineMedium,
                ),
                const SizedBox(height: 20),
                Text('Sheet Size: ${_currentSize.toStringAsFixed(2)}'),
                Text('Sheet Status: $_currentSheetStatus'),
              ],
            ),
          ),
          GrabberSheet(
            controller: _grabberSheetController, // Attach the controller
            initialChildSize: 0.5,
            minChildSize: 0.2,
            maxChildSize: 0.8
            ...
            onSizeChanged: (size) {
              if (mounted) {
                setState(() {
                  _currentSheetStatus = 'Dragging/Resizing';
                  _currentSize = size;
                });
              }
            },
            // Callback: Triggered when snapped
            onSnap: (size) {
              if (mounted) {
                setState(() {
                  _currentSheetStatus = 'Snapped to ${size.toStringAsFixed(2)}';
                  _currentSize = size;
                });
              }
            },
            builder: (BuildContext context, ScrollController scrollController) {
              return ListView.builder(
                controller: scrollController,
                itemCount: 30,
                itemBuilder: (BuildContext context, int index) {
                  return ListTile(
                    title: Text(
                      'Item $index',
                      style: TextStyle(color: theme.colorScheme.onSurface),
                    ),
                  );
                },
              );
            },
          ),
        ],
      ),
    );
  }
}
Example of GrabberSheet with FAB control

Showing the Grabber on Desktop and Web

By default, the grabber handle is only visible on mobile platforms (iOS, Android). However, you can force it to always be visible on desktop (Windows, macOS, Linux) and web platforms by setting the showGrabberOnNonMobile property to true. This is useful when you want to provide a consistent UI across all platforms.

GrabberSheet(
  showGrabberOnNonMobile: true,
  builder: (context, scrollController) {
    // ... Your content
  },
),

grabber_sheet_web

Properties

GrabberSheet

Property Type Description Default Value
builder Widget Function(BuildContext, ScrollController) Required. Builds the scrollable content of the sheet. Provides a ScrollController to be used by the content. -
initialChildSize double The initial fractional size of the sheet. 0.5
minChildSize double The minimum fractional size of the sheet. 0.25
maxChildSize double The maximum fractional size of the sheet. 1.0
snap bool If true, the sheet will snap to the nearest snap point after dragging. false
snapSizes List<double>? A list of intermediate fractional sizes to snap to. null
showGrabber bool Whether to show the grabber handle. It is automatically hidden on desktop and web platforms regardless of this value. true
grabberStyle GrabberStyle The visual style of the grabber handle. const GrabberStyle()
bottom Widget? A custom widget to display below the grabber and above the main content. null
bottomAreaPadding EdgeInsetsGeometry? The padding for the bottom widget area. null
backgroundColor Color? The background color of the sheet container. If null, it uses the theme's colorScheme.surface. Theme.of(context).colorScheme.surface
borderRadius BorderRadiusGeometry? The border radius of the sheet. If null, defaults to a top-left and top-right radius of 16.0. const BorderRadius.vertical(top: Radius.circular(16.0))
controller GrabberSheetController? An optional controller to programmatically control the sheet's size and state. Provides maximize(), minimize(), and all DraggableScrollableController methods. null
onSizeChanged ValueChanged<double>? Callback that is called whenever the sheet's fractional size changes (during dragging or animation). null
onSnap ValueChanged<double>? Callback that is called when the sheet completes a snap animation and settles at a specific fractional size. null

GrabberStyle

Property Type Description Default Value
color Color The background color of the grabber handle. Colors.grey
width double The width of the grabber handle. 48.0
height double The height of the grabber handle. 5.0
radius Radius The border radius of the grabber handle's corners. const Radius.circular(8.0)
margin EdgeInsetsGeometry The margin surrounding the grabber handle. const EdgeInsets.symmetric(vertical: 10.0)

Additional information

To file issues, request features, or contribute, please visit the GitHub repository.

Libraries

grabber_sheet