morphing_sheet 0.2.1 copy "morphing_sheet: ^0.2.1" to clipboard
morphing_sheet: ^0.2.1 copied to clipboard

A production-ready multi-stage, gesture-driven, spatially continuous UX sheet with physics-based snapping and morphing fullscreen transitions.

pub version likes popularity

morphing_sheet #

A production-ready Flutter package implementing a context-aware, gesture-driven, spatially continuous morphing interaction container with physics-based snapping and item-driven fullscreen transitions.

Features #

  • Two operating modes:
    • Page-driven (v0.1.x) -- horizontal pages with snap points
    • List-driven (v0.2.0) -- item selection with preview & detail morph
  • Three-stage sheet -- collapsed (25%), half-expanded (60%), fullscreen (100%) with smooth continuous transitions between them.
  • Physics-based gestures -- velocity-aware snap resolution, rubber-band resistance at bounds, magnetic snap attraction, and configurable flick thresholds.
  • Single animation source of truth -- one AnimationController drives all visual properties (height, radius, elevation, blur, scale) preventing rebuild storms.
  • Deterministic state machine -- State = f(progress, selectedItem). No implicit UI state. No state inside widgets.
  • State-management agnostic -- SheetController extends ChangeNotifier, works with Provider, Riverpod, Bloc, or vanilla ListenableBuilder.
  • Contextual morph transitions -- tap an item to morph the sheet into a preview, drag up to fullscreen detail, drag down to dismiss.
  • Platform-aware physics -- iOS-tuned softer rubber-band, Android stiffer defaults.
  • Performance optimized -- RepaintBoundary isolation on 4 layers, ListView.builder for 200+ items, no AnimatedOpacity / AnimatedContainer.

Installation #

dependencies:
  morphing_sheet: ^0.2.0 ##    or from pub once published

Quick Start: Page-Driven Mode #

import 'package:morphing_sheet/morphing_sheet.dart';

class MyPage extends StatefulWidget {
  @override
  State<MyPage> createState() => _MyPageState();
}

class _MyPageState extends State<MyPage>
    with SingleTickerProviderStateMixin {
  late final SheetController _controller;

  @override
  void initState() {
    super.initState();
    _controller = SheetController(vsync: this);
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: MorphingSheet(
        controller: _controller,
        pageCount: 3,
        headerBuilder: (context, progress, state) {
          return Container(
            padding: const EdgeInsets.all(16),
            child: Text('Progress: ${(progress * 100).toInt()}%'),
          );
        },
        contentBuilder: (context, index, progress, state) {
          return Center(child: Text('Page $index'));
        },
        child: const Center(child: Text('Background')),
      ),
    );
  }
}

Quick Start: List-Driven Mode (v0.2.0) #

import 'package:morphing_sheet/morphing_sheet.dart';

class ProductPage extends StatefulWidget {
  @override
  State<ProductPage> createState() => _ProductPageState();
}

class _ProductPageState extends State<ProductPage>
    with SingleTickerProviderStateMixin {
  late final SheetController _controller;
  final _products = <Product>[/* ... */];

  @override
  void initState() {
    super.initState();
    _controller = SheetController(vsync: this);
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: MorphingSheet.listDriven<Product>(
        controller: _controller,
        items: _products,
        itemBuilder: (context, product, progress, state) {
          return ListTile(
            title: Text(product.name),
            subtitle: Text('\$${product.price}'),
          );
        },
        previewBuilder: (context, product, progress, state) {
          return Column(
            children: [
              Image.network(product.imageUrl),
              Text(product.name, style: Theme.of(context).textTheme.headlineSmall),
              Text('\$${product.price}'),
              FilledButton(onPressed: () {}, child: Text('ADD')),
            ],
          );
        },
        detailBuilder: (context, product, progress, state) {
          return Column(
            children: [
              Image.network(product.imageUrl),
              Text(product.name),
              Text(product.description),
              Text('Similar Products'),
              // ...
            ],
          );
        },
        headerBuilder: (context, product, progress, state) {
          return Padding(
            padding: const EdgeInsets.all(16),
            child: Text(product?.name ?? 'Products'),
          );
        },
        child: const Center(child: Text('Background')),
      ),
    );
  }
}

Controller API #

// Programmatic control
controller.expand();
controller.collapse();
controller.snapTo(snapPoint);
controller.animateTo(0.6);

// List-driven selection
controller.selectItem(product);        // -> half-expanded preview
controller.animateToItemDetail(product); // -> expanded detail
controller.clearSelection();           // -> collapsed (deferred clear)

// Read-only state
controller.progress;       // 0.0 - 1.0
controller.selectedItem;   // Object? (list-driven mode)
controller.hasSelection;   // bool
controller.visualState;    // CollapsedState | HalfExpandedState | ExpandedState | TransitioningState
controller.state;          // SheetState (immutable snapshot)

Architecture #

lib/
 ├── morphing_sheet.dart           # barrel export
 └── src/
      ├── core/
      │    └── sheet_physics.dart   # abstract physics interface
      ├── physics/
      │    ├── default_sheet_physics.dart
      │    └── snap_resolver.dart
      ├── controllers/
      │    └── sheet_controller.dart
      ├── models/
      │    ├── sheet_state.dart
      │    ├── sheet_config.dart
      │    ├── sheet_item.dart         # NEW in v0.2.0
      │    ├── sheet_visual_state.dart
      │    └── snap_point.dart
      ├── animations/
      │    ├── curve_presets.dart
      │    ├── item_morph_tween.dart   # NEW in v0.2.0
      │    ├── sheet_tween.dart
      │    └── transition_spec.dart
      └── widgets/
           ├── morphing_sheet_widget.dart
           ├── list_driven_sheet.dart   # NEW in v0.2.0
           ├── dynamic_list_layer.dart  # NEW in v0.2.0
           ├── preview_layer.dart       # NEW in v0.2.0
           ├── detail_layer.dart        # NEW in v0.2.0
           ├── sheet_scaffold.dart
           ├── gesture_layer.dart
           ├── background_transform.dart
           └── sheet_page_view.dart

Layer responsibilities #

Layer Purpose
Models Immutable state (SheetState), configuration (SheetConfig), value objects (SnapPoint, SheetItem)
Core Abstract interfaces (SheetPhysics) for dependency inversion
Physics Concrete physics (magnetic snap, platform tuning), pure-function snap resolution
Controllers SheetController -- owns AnimationController, exposes immutable state snapshots, manages item selection
Animations Interpolation helpers (SheetTween, ItemMorphTween), curve presets, per-property timing
Widgets Presentation layer -- MorphingSheet, ListDrivenMorphingSheet, layer widgets, scaffold, gestures

Configuration #

const config = SheetConfig(
  snapPoints: [
    SnapPoint(position: 0.2, label: 'collapsed'),
    SnapPoint(position: 0.5, label: 'half'),
    SnapPoint(position: 1.0, label: 'full', enableHorizontalSwipe: false),
  ],
  cornerRadius: 28,
  elevation: 12,
  backgroundMinScale: 0.92,
  backgroundMaxBlur: 8,
  expandCurve: SheetCurves.expand,
  collapseCurve: SheetCurves.collapse,
);

Custom Physics #

class MagneticPhysics extends SheetPhysics {
  const MagneticPhysics();

  @override
  double applyResistance(double delta, double pos, double min, double max) {
    return delta * 0.8;
  }

  @override
  SnapPoint resolveSnap(double pos, double velocity, List<SnapPoint> points) {
    return SnapResolver.resolve(pos, velocity, points, flickThreshold: 0.5);
  }

  @override
  bool shouldFlick(double velocity) => velocity.abs() > 0.5;
}

MorphingSheet(physics: const MagneticPhysics(), ...)

Or use the built-in magnetic snap:

MorphingSheet(
  physics: const DefaultSheetPhysics(
    enableMagneticSnap: true,
    magneticRange: 0.05,
    magneticStrength: 0.6,
  ),
  ...
)

Visual State Matching #

switch (controller.visualState) {
  case CollapsedState():
    // show minimal UI
  case HalfExpandedState():
    // show richer content
  case ExpandedState():
    // show full detail
  case TransitioningState(:final from, :final to, :final localProgress):
    // animate between states
}

Migration from v0.1.x #

No breaking changes. All existing MorphingSheet(...) usage works identically.

To adopt list-driven mode:

  1. Replace MorphingSheet(...) with MorphingSheet.listDriven<T>(...)
  2. Provide itemBuilder, previewBuilder, and detailBuilder
  3. Optionally use controller.selectItem() / controller.clearSelection() for programmatic control

Minimum Requirements #

  • Flutter >= 3.10.0
  • Dart >= 3.10.4

License #

MIT

3
likes
0
points
223
downloads

Publisher

unverified uploader

Weekly Downloads

A production-ready multi-stage, gesture-driven, spatially continuous UX sheet with physics-based snapping and morphing fullscreen transitions.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter

More

Packages that depend on morphing_sheet