sliding_pill_drawer

pub package pub points popularity likes License: MIT style: flutter_lints

A customizable side drawer with a draggable pill button that slides 1:1 with the panel. Supports a sticky pill pinned to the screen, an in-list follower pill that rides a LayerLink, and full RTL / LTR auto-mirroring β€” with zero external dependencies.

Demo

Three modes, recorded against the example app:

Sticky Follower Custom
Sticky mode demo Follower mode demo Custom pill demo

Stills from the example app:

Sticky mode at rest Sticky mode mid-drag Sticky mode fully open

Features

  • 🎯 Two placement modes β€” sticky (pinned to a fixed vertical offset) or follower (anchored to a target inside a scrollable).
  • 🀝 1:1 drag coupling β€” horizontal drag distance maps directly to panel translation; release snaps based on the halfway point.
  • 🌐 RTL / LTR aware β€” panel direction, pill chevron, and target anchor mirror automatically based on Directionality.
  • 🎨 Customizable β€” supply your own pillBuilder and/or backdropBuilder, or just override the default pill text.
  • πŸŽ› Imperative control β€” open / close / toggle and a listenable value for driving custom animations.
  • ⚑ Zero dependencies β€” pure Flutter, no third-party packages, small surface area.

Table of contents

Installation

dependencies:
  sliding_pill_drawer: ^1.0.1

Then:

import 'package:sliding_pill_drawer/sliding_pill_drawer.dart';

Quick start

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

  @override
  State<MyPage> createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> {
  final _controller = SlidingPillDrawerController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SlidingPillDrawer(
        controller: _controller,
        isSticky: true,
        drawerContent: const _Menu(),
        body: const Center(child: Text('Drag the pill β†’')),
      ),
    );
  }
}

Usage

Sticky mode

The pill stays pinned at a fixed vertical offset on the screen. Good for global navigation.

SlidingPillDrawer(
  controller: _controller,
  isSticky: true,
  stickyTop: 200, // pixels from top; defaults to 40% of height
  drawerContent: MyMenu(),
  body: MyPageContent(),
)

Follower mode

The pill rides along a target placed inside the body β€” perfect when the pill should scroll with a list item.

final _controller = SlidingPillDrawerController();
final _link = LayerLink();

SlidingPillDrawer(
  controller: _controller,
  link: _link,
  drawerContent: MyMenu(),
  body: ListView(
    children: [
      const Text('…long content…'),
      SlidingPillDrawerTarget(link: _link), // pill appears here and scrolls with the list
      const Text('…more content…'),
    ],
  ),
)

Custom pill

Provide a pillBuilder to fully replace the default pill. The supplied Animation<double> is 0.0 when closed and 1.0 when fully open.

SlidingPillDrawer(
  controller: _controller,
  isSticky: true,
  drawerContent: MyMenu(),
  body: MyPage(),
  pillBuilder: (context, animation) => MyOwnPillWidget(animation: animation),
)

Custom backdrop

Replace the default 50% black overlay with anything β€” gradients, blurs, image tints. Tap-to-close stays wired automatically.

SlidingPillDrawer(
  controller: _controller,
  isSticky: true,
  drawerContent: MyMenu(),
  body: MyPage(),
  backdropBuilder: (context, animation) => BackdropFilter(
    filter: ImageFilter.blur(
      sigmaX: animation.value * 8,
      sigmaY: animation.value * 8,
    ),
    child: ColoredBox(
      color: Colors.black.withValues(alpha: animation.value * 0.3),
    ),
  ),
)

Reading animation progress

SlidingPillDrawerController is a Listenable, so you can rebuild any widget against the drawer's animation:

ListenableBuilder(
  listenable: _controller,
  builder: (context, _) => Opacity(
    opacity: 1 - _controller.value,
    child: Text('${(_controller.value * 100).toStringAsFixed(0)}%'),
  ),
)

RTL

The widget reads from the ambient Directionality. Wrap or rely on your app's locale:

Directionality(
  textDirection: TextDirection.rtl,
  child: SlidingPillDrawer(/* ... */),
)

The panel slides in from the right, the pill chevron flips, and the follower target anchors to the right edge β€” automatically.

API reference

SlidingPillDrawer

Parameter Type Default Notes
body Widget required Page content behind the drawer.
drawerContent Widget required Panel content.
controller SlidingPillDrawerController required Drives open / close and exposes the animation.
link LayerLink? null Required in follower mode; pair with SlidingPillDrawerTarget.
isSticky bool false true = sticky pinned pill, false = follower (or no built-in pill if link is also null).
stickyTop double? 40% of height Sticky-mode vertical offset, in pixels from the top.
defaultPillText String 'Menu' Label on the built-in pill.
pillBuilder PillBuilder? null Provide your own pill widget.
backdropBuilder BackdropBuilder? null Custom backdrop layer; tap-to-close is wired regardless.
overlayBuilder Widget Function(...) null Renders extra widgets above backdrop and panel.
panelWidthFraction double 0.85 Fraction of screen width the panel occupies when open.
animationDuration Duration 350ms Open/close animation length.

SlidingPillDrawerController

A ChangeNotifier β€” listen to it to rebuild against animation progress.

Member Kind Description
value double getter / setter 0.0 (closed) … 1.0 (open). Setting clamps to range without animating; pair with settle() on drag end.
isOpen bool getter value > 0.5.
isFullyOpen bool getter Animation has finished opening (value == 1.0).
open() / close() / toggle() void Animated imperative control.
settle() void Snaps fully open or fully closed based on current value. Use on drag end of a custom pill.

SlidingPillDrawerTarget

Used in follower mode only. Reserves the slot where the pill sits inside a scrollable.

SlidingPillDrawerTarget(link: _link, width: 80, height: 40)
Parameter Type Default Notes
link LayerLink required Same instance passed to the parent SlidingPillDrawer.
width double 80 Reserved width (β‰ˆ pill width).
height double 40 Reserved height (β‰ˆ pill height).

DefaultPill

The built-in pill rendered when pillBuilder is omitted. Exported so you can reuse its visual style around a custom layout.

Typedefs

  • PillBuilder β€” Widget Function(BuildContext, Animation<double>)
  • BackdropBuilder β€” Widget Function(BuildContext, Animation<double>)

Architecture

In sticky mode, the pill is positioned imperatively on the leading edge of the panel using PositionedDirectional, sliding from 0 to panelWidth as the animation progresses.

In follower mode, the package uses Flutter's LayerLink primitive. SlidingPillDrawerTarget plants a CompositedTransformTarget inside the body; the host widget then renders a CompositedTransformFollower in a sibling layer that follows the target across scrolls. Drag offset is added on top of the linked position so the pill stays glued to the panel's trailing edge while opening.

For a deeper walk-through, see doc/architecture.md.

Example app

A runnable demo lives in example/. It wires up all three modes (sticky, follower, custom) and an RTL toggle.

cd example
flutter run

Roadmap

  • Right-side anchored drawer (mirror the leading-edge default).
  • Configurable snap thresholds (currently fixed at 50%).
  • Velocity-aware fling open/close.

Have an idea? File it under issues.

Contributing

PRs welcome. See CONTRIBUTING.md for setup, branching, commit, and test expectations. By participating you agree to the Code of Conduct.

License

MIT Β© Masoud Saeidi β€” see LICENSE.

Libraries

sliding_pill_drawer
A customizable side drawer with a draggable pill button that slides 1:1 with the panel.