diff --git a/cookbook/assets/fake_map.png b/cookbook/assets/fake_map.png new file mode 100644 index 00000000..67a45d0c Binary files /dev/null and b/cookbook/assets/fake_map.png differ diff --git a/cookbook/lib/showcase/airbnb_mobile_app.dart b/cookbook/lib/showcase/airbnb_mobile_app.dart index e69de29b..17dad39c 100644 --- a/cookbook/lib/showcase/airbnb_mobile_app.dart +++ b/cookbook/lib/showcase/airbnb_mobile_app.dart @@ -0,0 +1,561 @@ +import 'package:faker/faker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + // Lock the screen orientation to portrait. + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); + + runApp(const _AirbnbMobileAppExample()); +} + +class _AirbnbMobileAppExample extends StatelessWidget { + const _AirbnbMobileAppExample(); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const _Home(), + ); + } +} + +class _Home extends StatelessWidget { + const _Home(); + + @override + Widget build(BuildContext context) { + // Cache the system UI insets outside of the scaffold for later use. + // This is because the scaffold adds the height of the navigation bar + // to the padding.bottom of the inherited MediaQuery and re-exposes it + // to the descendant widgets. Therefore, the descendant widgets cannot get + // the net system UI insets. + final systemUiInsets = MediaQuery.of(context).padding; + + final result = Scaffold( + // Enable this flag since we want the sheet handle to be drawn + // behind the tab bar when the sheet is fully expanded. + extendBody: true, + // Enable this flag since the navigation bar + // will be hidden when the sheet is dragged down. + extendBodyBehindAppBar: true, + appBar: const _AppBar(), + body: Stack( + children: [ + const _Map(), + _ContentSheet(systemUiInsets: systemUiInsets), + ], + ), + bottomNavigationBar: const _BottomNavigationBar(), + floatingActionButton: const _MapButton(), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + ); + + return DefaultTabController( + length: _AppBar.tabs.length, + // Provides a SheetController to the descendant widgets + // to perform some sheet extent driven animations. + // The sheet will look up and use this controller unless + // another one is manually specified in the constructor. + // The descendant widgets can also get this controller by + // calling 'DefaultSheetController.of(context)'. + child: DefaultSheetController( + child: result, + ), + ); + } +} + +class _MapButton extends StatelessWidget { + const _MapButton(); + + @override + Widget build(BuildContext context) { + final sheetController = DefaultSheetController.of(context); + + void onPressed() { + final metrics = sheetController.metrics; + if (metrics != null) { + // Collapse the sheet to reveal the map behind. + sheetController.animateTo( + Extent.pixels(metrics.minPixels), + curve: Curves.fastOutSlowIn, + ); + } + } + + final result = FloatingActionButton.extended( + onPressed: onPressed, + backgroundColor: Colors.black, + label: const Text('Map'), + icon: const Icon(Icons.map), + ); + + // It is easy to create sheet extent driven animations + // by using 'ExtentDrivenAnimation', a special kind of + // 'Animation' whose value changes from 0 to 1 as + // the sheet extent changes from 'startExtent' to 'endExtent'. + final animation = ExtentDrivenAnimation( + controller: DefaultSheetController.of(context), + // The initial value of the animation is required + // since the sheet extent is not available at the first build. + initialValue: 1, + // If null, the minimum extent will be used. (Default) + startExtent: null, + // If null, the maximum extent will be used. (Default) + endExtent: null, + ).drive(CurveTween(curve: Curves.easeInExpo)); + + // Hide the button when the sheet is dragged down. + return ScaleTransition( + scale: animation, + child: FadeTransition( + opacity: animation, + child: result, + ), + ); + } +} + +class _Map extends StatelessWidget { + const _Map(); + + @override + Widget build(BuildContext context) { + return SafeArea( + bottom: false, + child: SizedBox.expand( + child: Image.asset( + 'assets/fake_map.png', + fit: BoxFit.fitHeight, + ), + ), + ); + } +} + +class _ContentSheet extends StatelessWidget { + const _ContentSheet({ + required this.systemUiInsets, + }); + + final EdgeInsets systemUiInsets; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final parentHeight = constraints.maxHeight; + final appbarHeight = MediaQuery.of(context).padding.top; + final handleHeight = const _ContentSheetHandle().preferredSize.height; + final sheetHeight = parentHeight - appbarHeight + handleHeight; + final minSheetExtent = + Extent.pixels(handleHeight + systemUiInsets.bottom); + + const sheetShape = RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ); + + final sheetPhysics = StretchingSheetPhysics( + parent: SnappingSheetPhysics( + snappingBehavior: SnapToNearest( + snapTo: [ + minSheetExtent, + const Extent.proportional(1), + ], + // The greater 'maxFlingVelocityToSnap' is, the more likely + // the sheet will snap to the nearest stop position while scrolling. + // Try to increase/decrease this value to see the difference. + maxFlingVelocityToSnap: 4000, + ), + ), + ); + + return ScrollableSheet( + physics: sheetPhysics, + minExtent: minSheetExtent, + child: SizedBox( + height: sheetHeight, + child: const Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: sheetShape, + child: Column( + children: [ + _ContentSheetHandle(), + Expanded(child: _HouseList()), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _ContentSheetHandle extends StatelessWidget + implements PreferredSizeWidget { + const _ContentSheetHandle(); + + @override + Size get preferredSize => const Size.fromHeight(80); + + @override + Widget build(BuildContext context) { + return SheetDraggable( + child: SizedBox( + height: preferredSize.height, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + buildIndicator(), + const SizedBox(height: 16), + Expanded( + child: Center( + child: Text( + '646 national park homes', + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget buildIndicator() { + return Container( + height: 6, + width: 40, + decoration: const ShapeDecoration( + color: Colors.black12, + shape: StadiumBorder(), + ), + ); + } +} + +class _HouseList extends StatelessWidget { + const _HouseList(); + + @override + Widget build(BuildContext context) { + final result = ListView.builder( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + itemCount: _houses.length, + itemBuilder: (context, index) { + return _HouseCard(_houses[index]); + }, + ); + + // Hide the list when the sheet is dragged down. + return FadeTransition( + opacity: ExtentDrivenAnimation( + controller: DefaultSheetController.of(context), + initialValue: 1, + ).drive( + CurveTween(curve: Curves.easeOutCubic), + ), + child: result, + ); + } +} + +class _House { + const _House({ + required this.title, + required this.rating, + required this.distance, + required this.charge, + required this.image, + }); + + factory _House.random() { + return _House( + title: '${faker.address.city()}, ${faker.address.country()}', + rating: faker.randomGenerator.decimal(scale: 1.5, min: 3.5), + distance: faker.randomGenerator.integer(300, min: 50), + charge: faker.randomGenerator.integer(2000, min: 500), + image: faker.image.image( + width: 300, + height: 300, + random: true, + keywords: ['cottage'], + ), + ); + } + + final String title; + final double rating; + final int distance; + final int charge; + final String image; +} + +class _AppBar extends StatelessWidget implements PreferredSizeWidget { + const _AppBar(); + + static const tabs = [ + Tab(text: 'National parks', icon: Icon(Icons.forest_outlined)), + Tab(text: 'Tiny homes', icon: Icon(Icons.cabin_outlined)), + Tab(text: 'Ryokan', icon: Icon(Icons.hotel_outlined)), + Tab(text: 'Play', icon: Icon(Icons.celebration_outlined)), + ]; + + static const topHeight = 90.0; + + // The tab bar height is defined in: + // https://github.com/flutter/flutter/blob/78666c8dc57e9f7548ca9f8dd0740fbf0c658dc9/packages/flutter/lib/src/material/tabs.dart#L29 + static const bottomHeight = 72.0; + + @override + Size get preferredSize => const Size.fromHeight(topHeight + bottomHeight); + + @override + Widget build(BuildContext context) { + return AppBar( + elevation: 1, + backgroundColor: Colors.white, + toolbarHeight: topHeight, + title: buildToolBar(context), + bottom: buildTabBar(), + ); + } + + PreferredSizeWidget buildTabBar() { + return const TabBar( + tabs: tabs, + labelColor: Colors.black, + unselectedLabelColor: Colors.black54, + indicatorColor: Colors.black, + ); + } + + Widget buildToolBar(BuildContext context) { + return SizedBox( + height: topHeight, + child: Row( + children: [ + Expanded(child: buildSearchBox(context)), + const SizedBox(width: 16), + buildFilterButton(context), + ], + ), + ); + } + + Widget buildSearchBox(BuildContext context) { + final inputArea = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Where to?', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 4), + Text( + 'Anywhere · Any week · Add guest', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(color: Colors.black54), + ), + ], + ); + + const decoration = ShapeDecoration( + color: Colors.white, + shape: StadiumBorder( + side: BorderSide(color: Colors.black12), + ), + shadows: [ + BoxShadow( + color: Color(0x0a000000), + spreadRadius: 4, + blurRadius: 8, + offset: Offset(1, 1), + ), + ], + ); + + return Container( + height: double.infinity, + margin: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: decoration, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.search, color: Colors.black), + const SizedBox(width: 12), + Expanded(child: inputArea), + ], + ), + ); + } + + Widget buildFilterButton(BuildContext context) { + return IconButton( + onPressed: () {}, + color: Colors.black, + icon: const Icon(Icons.tune_outlined), + ); + } +} + +class _BottomNavigationBar extends StatelessWidget { + const _BottomNavigationBar(); + + @override + Widget build(BuildContext context) { + final result = BottomNavigationBar( + unselectedItemColor: Colors.black54, + selectedItemColor: Colors.pink, + type: BottomNavigationBarType.fixed, + showUnselectedLabels: true, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.search), + label: 'Explore', + ), + BottomNavigationBarItem( + icon: Icon(Icons.favorite_border_outlined), + label: 'Wishlists', + ), + BottomNavigationBarItem( + icon: Icon(Icons.luggage_outlined), + label: 'Trips', + ), + BottomNavigationBarItem( + icon: Icon(Icons.inbox_outlined), + label: 'Inbox', + ), + BottomNavigationBarItem( + icon: Icon(Icons.person_outline), + label: 'Profile', + ), + ], + ); + + // Hide the navigation bar when the sheet is dragged down. + return SlideTransition( + position: ExtentDrivenAnimation( + controller: DefaultSheetController.of(context), + initialValue: 1, + ).drive( + Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ), + ), + child: result, + ); + } +} + +class _HouseCard extends StatelessWidget { + const _HouseCard(this.house); + + final _House house; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final primaryTextStyle = + textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold); + final secondaryTextStyle = textTheme.titleMedium; + final tertiaryTextStyle = + textTheme.titleMedium?.copyWith(color: Colors.black54); + + final image = Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: AspectRatio( + aspectRatio: 1.2, + child: Image.network( + house.image, + fit: BoxFit.fitWidth, + ), + ), + ); + + final rating = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.star_rounded, color: secondaryTextStyle?.color, size: 18), + const SizedBox(width: 4), + Text(house.rating.toStringAsFixed(1), style: secondaryTextStyle), + ], + ); + + final heading = Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + house.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: primaryTextStyle, + ), + ), + const SizedBox(width: 8), + rating, + ], + ); + + final description = [ + Text('${house.distance} kilometers away', style: tertiaryTextStyle), + const SizedBox(height: 4), + Text('5 nights · Jan 14 - 19', style: tertiaryTextStyle), + const SizedBox(height: 16), + Text( + '\$${house.charge} total before taxes', + style: secondaryTextStyle?.copyWith( + decoration: TextDecoration.underline, + ), + ), + ]; + + return InkWell( + onTap: () {}, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + image, + const SizedBox(height: 16), + heading, + const SizedBox(height: 8), + ...description, + ], + ), + ), + ); + } +} + +final _houses = List.generate(50, (_) => _House.random()); diff --git a/cookbook/lib/tutorial/modal_sheets.dart b/cookbook/lib/showcase/todo_list.dart similarity index 100% rename from cookbook/lib/tutorial/modal_sheets.dart rename to cookbook/lib/showcase/todo_list.dart diff --git a/cookbook/lib/tutorial/sheet_draggable.dart b/cookbook/lib/tutorial/declarative_modal_sheet.dart similarity index 100% rename from cookbook/lib/tutorial/sheet_draggable.dart rename to cookbook/lib/tutorial/declarative_modal_sheet.dart diff --git a/cookbook/lib/tutorial/declarative_navigation_sheet.dart b/cookbook/lib/tutorial/declarative_navigation_sheet.dart index e69de29b..52852291 100644 --- a/cookbook/lib/tutorial/declarative_navigation_sheet.dart +++ b/cookbook/lib/tutorial/declarative_navigation_sheet.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +// The code may seem verbose, but the core principle is straightforward. +// In this tutorial, you only need to learn the following: +// +// 1. Create a Navigator and wrap it in a NavigationSheet. +// 2. Use *NavigationSheetPage to create a page that belongs to the navigator. +// 3. Do not forget to register a NavigationSheetTransitionObserver to the navigator. +void main() { + runApp(const _DeclarativeNavigationSheetExample()); +} + +// NavigationSheet requires a special NavigatorObserver in order to +// smoothly change its extent during a route transition. +final transitionObserver = NavigationSheetTransitionObserver(); + +// To use declarative navigation, we utilize the 'go_router' package. +// However, any other package that works with Navigator 2.0 +// or even your own implementation can also be used. +final router = GoRouter( + initialLocation: '/a', + routes: [ + // We use ShellRoute to create a Navigator + // that will be used for nested navigation in the sheet. + ShellRoute( + // Do not forget this line! + observers: [transitionObserver], + builder: (context, state, child) { + return _ExampleHome(nestedNavigator: child); + }, + routes: [ + GoRoute( + path: '/a', + pageBuilder: (context, state) { + return DraggableNavigationSheetPage( + key: state.pageKey, + child: const _ExampleSheetContent( + title: '/a', + size: 0.5, + destinations: ['/a/details', '/b'], + ), + ); + }, + routes: [ + GoRoute( + path: 'details', + pageBuilder: (context, state) { + return DraggableNavigationSheetPage( + key: state.pageKey, + child: const _ExampleSheetContent( + title: '/a/details', + size: 0.75, + destinations: ['/a/details/info'], + ), + ); + }, + routes: [ + GoRoute( + path: 'info', + pageBuilder: (context, state) { + return DraggableNavigationSheetPage( + key: state.pageKey, + child: const _ExampleSheetContent( + title: '/a/details/info', + size: 1.0, + destinations: ['/a', '/b', '/b/details'], + ), + ); + }, + ), + ], + ), + ], + ), + GoRoute( + path: '/b', + pageBuilder: (context, state) { + return DraggableNavigationSheetPage( + key: state.pageKey, + child: const _ExampleSheetContent( + title: 'B', + size: 0.6, + destinations: ['/b/details', '/a'], + ), + ); + }, + routes: [ + GoRoute( + path: 'details', + pageBuilder: (context, state) { + return DraggableNavigationSheetPage( + key: state.pageKey, + child: const _ExampleSheetContent( + title: 'B Details', + size: 0.5, + destinations: ['/a'], + ), + ); + }, + ), + ], + ), + ], + ), + ], +); + +class _DeclarativeNavigationSheetExample extends StatelessWidget { + const _DeclarativeNavigationSheetExample(); + + @override + Widget build(BuildContext context) { + return MaterialApp.router(routerConfig: router); + } +} + +class _ExampleHome extends StatelessWidget { + const _ExampleHome({ + required this.nestedNavigator, + }); + + final Widget nestedNavigator; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + const Placeholder(), + _ExampleSheet(nestedNavigator: nestedNavigator), + ], + ), + ); + } +} + +class _ExampleSheet extends StatelessWidget { + const _ExampleSheet({ + required this.nestedNavigator, + }); + + final Widget nestedNavigator; + + @override + Widget build(BuildContext context) { + return NavigationSheet( + transitionObserver: transitionObserver, + child: Material( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: nestedNavigator, + ), + ); + } +} + +class _ExampleSheetContent extends StatelessWidget { + const _ExampleSheetContent({ + required this.title, + required this.size, + required this.destinations, + }); + + final String title; + final double size; + final List destinations; + + @override + Widget build(BuildContext context) { + final textColor = Theme.of(context).colorScheme.onSecondaryContainer; + final textStyle = Theme.of(context).textTheme.displayMedium?.copyWith( + fontWeight: FontWeight.bold, + color: textColor, + ); + + return LayoutBuilder( + builder: (context, constraints) { + return Container( + color: Theme.of(context).colorScheme.secondaryContainer, + width: constraints.maxWidth, + height: constraints.maxHeight * size, + child: SafeArea( + child: Column( + children: [ + Expanded( + child: Center( + child: Text(title, style: textStyle), + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final dest in destinations) + TextButton( + style: TextButton.styleFrom( + foregroundColor: textColor, + ), + onPressed: () => context.go(dest), + child: Text('Go To $dest'), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/cookbook/lib/tutorial/imperative_modal_sheet.dart b/cookbook/lib/tutorial/imperative_modal_sheet.dart new file mode 100644 index 00000000..e69de29b diff --git a/cookbook/lib/tutorial/sheet_content_scaffold.dart b/cookbook/lib/tutorial/sheet_content_scaffold.dart index e69de29b..7cc208ea 100644 --- a/cookbook/lib/tutorial/sheet_content_scaffold.dart +++ b/cookbook/lib/tutorial/sheet_content_scaffold.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +void main() { + runApp(const _SheetContentScaffoldExample()); +} + +class _SheetContentScaffoldExample extends StatelessWidget { + const _SheetContentScaffoldExample(); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold( + body: Stack( + children: [ + Placeholder(), + _ExampleSheet(), + ], + ), + ), + ); + } +} + +class _ExampleSheet extends StatelessWidget { + const _ExampleSheet(); + + @override + Widget build(BuildContext context) { + final content = SheetContentScaffold( + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + requiredMinExtentForBottomBar: const Extent.proportional(0.5), + body: Container(height: 500), + appBar: buildAppBar(context), + bottomBar: buildBottomBar(), + ); + + const physics = StretchingSheetPhysics( + parent: SnappingSheetPhysics( + snappingBehavior: SnapToNearest( + snapTo: [ + Extent.proportional(0.2), + Extent.proportional(0.5), + Extent.proportional(1), + ], + ), + ), + ); + + return DraggableSheet( + physics: physics, + minExtent: const Extent.pixels(0), + child: Card( + clipBehavior: Clip.antiAlias, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: content, + ), + ); + } + + PreferredSizeWidget buildAppBar(BuildContext context) { + return AppBar( + title: const Text('Appbar'), + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + ); + } + + Widget buildBottomBar() { + return BottomAppBar( + child: Row( + children: [ + Flexible( + fit: FlexFit.tight, + child: TextButton( + onPressed: () {}, + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 16), + Flexible( + fit: FlexFit.tight, + child: FilledButton( + onPressed: () {}, + child: const Text('OK'), + ), + ) + ], + ), + ); + } +} diff --git a/cookbook/lib/tutorial/sheet_controller.dart b/cookbook/lib/tutorial/sheet_controller.dart index e69de29b..511dbbb3 100644 --- a/cookbook/lib/tutorial/sheet_controller.dart +++ b/cookbook/lib/tutorial/sheet_controller.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +void main() { + runApp(const _SheetControllerExample()); +} + +class _SheetControllerExample extends StatelessWidget { + const _SheetControllerExample(); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: _ExampleHome(), + ); + } +} + +class _ExampleHome extends StatefulWidget { + const _ExampleHome(); + + @override + State<_ExampleHome> createState() => _ExampleHomeState(); +} + +class _ExampleHomeState extends State<_ExampleHome> { + late final SheetController controller; + + @override + void initState() { + super.initState(); + controller = SheetController(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, child) { + return Text( + 'Extent: ${value?.toStringAsFixed(1)}', + style: Theme.of(context).textTheme.displaySmall, + ); + }, + ), + ), + ), + _ExampleSheet( + controller: controller, + ), + ], + ), + floatingActionButton: FloatingActionButton( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + child: const Icon(Icons.arrow_downward_rounded), + onPressed: () { + controller.animateTo(const Extent.proportional(0.5)); + }, + ), + ); + } +} + +class _ExampleSheet extends StatelessWidget { + const _ExampleSheet({ + required this.controller, + }); + + final SheetController controller; + + @override + Widget build(BuildContext context) { + return DraggableSheet( + controller: controller, + minExtent: const Extent.proportional(0.5), + physics: const StretchingSheetPhysics( + parent: SnappingSheetPhysics( + snappingBehavior: SnapToNearest( + snapTo: [ + Extent.proportional(0.5), + Extent.proportional(1), + ], + ), + ), + ), + child: Card( + margin: EdgeInsets.zero, + color: Theme.of(context).colorScheme.secondaryContainer, + child: const SizedBox( + height: 500, + width: double.infinity, + ), + ), + ); + } +} diff --git a/cookbook/pubspec.yaml b/cookbook/pubspec.yaml index 05809fbb..fc435224 100644 --- a/cookbook/pubspec.yaml +++ b/cookbook/pubspec.yaml @@ -8,6 +8,7 @@ environment: dependencies: animations: ^2.0.10 + faker: ^2.1.0 flutter: sdk: flutter go_router: ^12.1.3 @@ -22,3 +23,5 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/fake_map.png diff --git a/package/lib/smooth_sheets.dart b/package/lib/smooth_sheets.dart index 25fbdb3b..60ff3c28 100644 --- a/package/lib/smooth_sheets.dart +++ b/package/lib/smooth_sheets.dart @@ -1,10 +1,11 @@ export 'src/draggable/draggable_sheet.dart'; export 'src/draggable/sheet_draggable.dart'; +export 'src/foundation/animation.dart'; export 'src/foundation/framework.dart'; export 'src/foundation/modal_sheet.dart'; export 'src/foundation/sheet_activity.dart'; export 'src/foundation/sheet_content_scaffold.dart'; -export 'src/foundation/sheet_controller.dart'; +export 'src/foundation/sheet_controller.dart' hide SheetControllerScope; export 'src/foundation/sheet_extent.dart' hide // TODO: Export these classes when they are ready. diff --git a/package/lib/src/foundation/animation.dart b/package/lib/src/foundation/animation.dart new file mode 100644 index 00000000..313e573a --- /dev/null +++ b/package/lib/src/foundation/animation.dart @@ -0,0 +1,61 @@ +import 'package:flutter/animation.dart'; +import 'package:smooth_sheets/src/foundation/sheet_controller.dart'; +import 'package:smooth_sheets/src/foundation/sheet_extent.dart'; + +class ExtentDrivenAnimation extends Animation { + ExtentDrivenAnimation({ + required SheetController controller, + required this.initialValue, + this.startExtent, + this.endExtent, + }) : _controller = controller, + assert(initialValue >= 0.0 && initialValue <= 1.0); + + final SheetController _controller; + final double initialValue; + final Extent? startExtent; + final Extent? endExtent; + + @override + void addListener(VoidCallback listener) { + _controller.addListener(listener); + } + + @override + void removeListener(VoidCallback listener) { + _controller.removeListener(listener); + } + + @override + void addStatusListener(AnimationStatusListener listener) { + // The status will never change. + } + + @override + void removeStatusListener(AnimationStatusListener listener) { + // The status will never change. + } + + @override + AnimationStatus get status => AnimationStatus.forward; + + @override + double get value { + final metrics = _controller.metrics; + if (metrics == null) { + return initialValue; + } + + final startPixels = + startExtent?.resolve(metrics.contentDimensions) ?? metrics.minPixels; + final endPixels = + endExtent?.resolve(metrics.contentDimensions) ?? metrics.maxPixels; + final distance = endPixels - startPixels; + + if (distance.isFinite && distance > 0) { + return ((metrics.pixels - startPixels) / distance).clamp(0, 1); + } + + return 1; + } +} diff --git a/package/lib/src/foundation/sheet_controller.dart b/package/lib/src/foundation/sheet_controller.dart index 2ba69b2b..a2d4c1f9 100644 --- a/package/lib/src/foundation/sheet_controller.dart +++ b/package/lib/src/foundation/sheet_controller.dart @@ -1,17 +1,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; import 'package:smooth_sheets/src/foundation/sheet_extent.dart'; class SheetController extends ChangeNotifier - implements ValueListenable { + implements ValueListenable { SheetExtent? _client; @override - double get value { - assert(_client != null && _client!.hasPixels); - return _client!.pixels!; - } + double? get value => _client?.pixels; SheetMetrics? get metrics { return _client?.hasPixels == true ? _client!.metrics : null; @@ -63,6 +61,7 @@ class SheetController extends ChangeNotifier } } +@internal class SheetControllerScope extends InheritedWidget { const SheetControllerScope({ super.key, @@ -87,3 +86,47 @@ class SheetControllerScope extends InheritedWidget { return controller != oldWidget.controller; } } + +class DefaultSheetController extends StatefulWidget { + const DefaultSheetController({ + super.key, + required this.child, + }); + + final Widget child; + + @override + State createState() => _DefaultSheetControllerState(); + + static SheetController of(BuildContext context) { + return SheetControllerScope.of(context); + } + + static SheetController? maybeOf(BuildContext context) { + return SheetControllerScope.maybeOf(context); + } +} + +class _DefaultSheetControllerState extends State { + late final SheetController _controller; + + @override + void initState() { + super.initState(); + _controller = SheetController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SheetControllerScope( + controller: _controller, + child: widget.child, + ); + } +}