morphing_sheet 0.1.0
morphing_sheet: ^0.1.0 copied to clipboard
A production-ready multi-stage, gesture-driven, spatially continuous UX sheet with physics-based snapping and morphing fullscreen transitions.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:morphing_sheet/morphing_sheet.dart';
void main() {
runApp(const MorphingSheetExampleApp());
}
class MorphingSheetExampleApp extends StatelessWidget {
const MorphingSheetExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Morphing Sheet Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: const Color(0xFF6750A4),
brightness: Brightness.light,
useMaterial3: true,
),
darkTheme: ThemeData(
colorSchemeSeed: const Color(0xFF6750A4),
brightness: Brightness.dark,
useMaterial3: true,
),
home: const DemoPage(),
);
}
}
class DemoPage extends StatefulWidget {
const DemoPage({super.key});
@override
State<DemoPage> createState() => _DemoPageState();
}
class _DemoPageState extends State<DemoPage>
with SingleTickerProviderStateMixin {
late final SheetController _controller;
static const _config = SheetConfig(
snapPoints: [
SnapPoint(position: 0.25, label: 'collapsed'),
SnapPoint(position: 0.6, label: 'half'),
SnapPoint(position: 1.0, label: 'expanded', enableHorizontalSwipe: false),
],
cornerRadius: 28,
elevation: 12,
backgroundMinScale: 0.92,
backgroundMaxBlur: 8,
);
static const _pages = ['Explore', 'Trending', 'Library'];
@override
void initState() {
super.initState();
_controller = SheetController(vsync: this, config: _config);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: MorphingSheet(
controller: _controller,
config: _config,
pageCount: _pages.length,
headerBuilder: _buildHeader,
contentBuilder: _buildContent,
onPageChanged: (index) {},
child: const _BackgroundContent(),
),
);
}
Widget _buildHeader(
BuildContext context,
double progress,
SheetState state,
) {
final theme = Theme.of(context);
final normalized = SheetTween.normalize(
progress,
_config.snapPoints.first.position,
_config.snapPoints.last.position,
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(
children: [
// Drag handle
Center(
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
// Title row
Row(
children: [
Text(
_pages[state.selectedIndex],
style: TextStyle.lerp(
theme.textTheme.titleMedium,
theme.textTheme.headlineSmall,
normalized,
),
),
const Spacer(),
// Control buttons
_ControlButton(
icon: Icons.expand_less,
onTap: _controller.expand,
tooltip: 'Expand',
),
const SizedBox(width: 4),
_ControlButton(
icon: Icons.expand_more,
onTap: _controller.collapse,
tooltip: 'Collapse',
),
],
),
// Page indicator (fades in as sheet expands)
if (normalized > 0.1)
Opacity(
opacity: normalized.clamp(0.0, 1.0),
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: List.generate(_pages.length, (i) {
final isActive = i == state.selectedIndex;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isActive
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
),
);
}),
),
),
),
],
),
),
);
}
Widget _buildContent(
BuildContext context,
int index,
double progress,
SheetState state,
) {
final theme = Theme.of(context);
final items = _generateItems(index);
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
physics: const BouncingScrollPhysics(),
itemCount: items.length,
itemBuilder: (context, i) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: theme.colorScheme.primaryContainer,
child: Text(
'${i + 1}',
style: TextStyle(color: theme.colorScheme.onPrimaryContainer),
),
),
title: Text(items[i]),
subtitle: Text('${_pages[index]} item ${i + 1}'),
trailing: Icon(
Icons.chevron_right,
color: theme.colorScheme.onSurfaceVariant,
),
),
);
},
);
}
List<String> _generateItems(int pageIndex) {
switch (pageIndex) {
case 0:
return List.generate(20, (i) => 'Discovery Item ${i + 1}');
case 1:
return List.generate(15, (i) => 'Trending Topic ${i + 1}');
case 2:
return List.generate(25, (i) => 'Library Entry ${i + 1}');
default:
return [];
}
}
}
class _ControlButton extends StatelessWidget {
const _ControlButton({
required this.icon,
required this.onTap,
required this.tooltip,
});
final IconData icon;
final VoidCallback onTap;
final String tooltip;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(icon, size: 20),
),
),
);
}
}
class _BackgroundContent extends StatelessWidget {
const _BackgroundContent();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
theme.colorScheme.primaryContainer,
theme.colorScheme.surface,
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Morphing Sheet', style: theme.textTheme.headlineLarge),
const SizedBox(height: 8),
Text(
'Drag the sheet up and down, or use the buttons.\n'
'Swipe horizontally between pages when not fully expanded.',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 32),
Wrap(
spacing: 12,
runSpacing: 12,
children: List.generate(
6,
(i) => Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.center,
child: Icon(
_bgIcons[i % _bgIcons.length],
size: 32,
color: theme.colorScheme.onSecondaryContainer,
),
),
),
),
],
),
),
),
);
}
static const _bgIcons = [
Icons.shopping_bag_outlined,
Icons.music_note_outlined,
Icons.dashboard_outlined,
Icons.explore_outlined,
Icons.auto_awesome_outlined,
Icons.bar_chart_outlined,
];
}