morphing_sheet 0.5.0
morphing_sheet: ^0.5.0 copied to clipboard
A production-ready Flutter package for contextual morphing containers — supporting both card carousel snapping and Instamart-style overlay card transitions.
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
AnimationControllerdrives 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 --
SheetControllerextendsChangeNotifier, works with Provider, Riverpod, Bloc, or vanillaListenableBuilder. - 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 --
RepaintBoundaryisolation on 4 layers,ListView.builderfor 200+ items, noAnimatedOpacity/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:
- Replace
MorphingSheet(...)withMorphingSheet.listDriven<T>(...) - Provide
itemBuilder,previewBuilder, anddetailBuilder - Optionally use
controller.selectItem()/controller.clearSelection()for programmatic control
Minimum Requirements #
- Flutter >= 3.10.0
- Dart >= 3.10.4
License #
MIT