Last active
November 17, 2025 12:33
-
-
Save hectorAguero/5527303dd58a60d69bd0d0ce6e581c73 to your computer and use it in GitHub Desktop.
DraggableScrollableSheet with Footer and Header
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'package:flutter/material.dart'; | |
| void main() { | |
| runApp(const MyApp()); | |
| } | |
| class MyApp extends StatelessWidget { | |
| const MyApp({super.key}); | |
| bool isMax(ScrollController scrollController) => | |
| scrollController.hasClients && | |
| scrollController.position.pixels == | |
| scrollController.position.maxScrollExtent; | |
| @override | |
| Widget build(BuildContext context) { | |
| return MaterialApp( | |
| debugShowCheckedModeBanner: false, | |
| home: Theme( | |
| data: Theme.of(context).copyWith( | |
| bottomSheetTheme: BottomSheetThemeData( | |
| backgroundColor: Colors.transparent, | |
| constraints: BoxConstraints(maxWidth: double.infinity), | |
| ), | |
| ), | |
| child: Scaffold( | |
| appBar: AppBar( | |
| title: Text("Title"), | |
| elevation: 0, | |
| backgroundColor: Colors.transparent, | |
| ), | |
| extendBodyBehindAppBar: true, | |
| body: SafeArea( | |
| top: true, | |
| child: ListView.builder( | |
| itemCount: 100, | |
| itemBuilder: (context, index) { | |
| return Padding( | |
| padding: EdgeInsets.all(16.0), | |
| child: ListTile(title: Text('Item $index')), | |
| ); | |
| }, | |
| ), | |
| ), | |
| bottomSheet: MyBottomSheet( | |
| headerBuilder: (_, scrollController) => Container( | |
| width: double.infinity, | |
| padding: const EdgeInsets.all(16.0), | |
| child: ListTile( | |
| dense: true, | |
| minVerticalPadding: 0, | |
| contentPadding: EdgeInsets.zero, | |
| title: Text( | |
| 'Title Summary', | |
| style: Theme.of(context).textTheme.headlineSmall, | |
| ), | |
| trailing: IconButton( | |
| onPressed: null, | |
| icon: Icon( | |
| isMax(scrollController) | |
| ? Icons.keyboard_arrow_up | |
| : Icons.keyboard_arrow_down, | |
| ), | |
| ), | |
| ), | |
| ), | |
| footerBuilder: (_, _) => Container( | |
| width: double.infinity, | |
| padding: const EdgeInsets.all(8.0), | |
| child: Center( | |
| child: Text( | |
| 'Footer Actions', | |
| style: Theme.of(context).textTheme.headlineMedium, | |
| ), | |
| ), | |
| ), | |
| contentBuilder: (context, controller) { | |
| return CustomScrollView( | |
| controller: controller, | |
| slivers: [ | |
| PinnedHeaderSliver(child: Text('Content Title Subheader')), | |
| SliverList.builder( | |
| itemCount: 3, | |
| itemBuilder: (context, index) { | |
| return Card( | |
| child: Padding( | |
| padding: const EdgeInsets.all(16.0), | |
| child: Column( | |
| spacing: 8.0, | |
| children: [ | |
| Text( | |
| 'Content Item $index', | |
| style: Theme.of( | |
| context, | |
| ).textTheme.headlineMedium, | |
| ), | |
| Text( | |
| '$index', | |
| style: Theme.of(context).textTheme.bodyMedium, | |
| ), | |
| Align( | |
| alignment: Alignment.bottomRight, | |
| child: Text( | |
| 'Total: \$${(index + 1) * 10}', | |
| style: Theme.of( | |
| context, | |
| ).textTheme.headlineMedium, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| }, | |
| ), | |
| ], | |
| ); | |
| }, | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class MyBottomSheet extends StatefulWidget { | |
| const MyBottomSheet({ | |
| super.key, | |
| this.footerHeaderHeight = 144, | |
| this.topMargin = 80.0, | |
| this.contentHeight, | |
| this.barrielColor = Colors.black54, | |
| this.footerBuilder, | |
| this.headerBuilder, | |
| this.showGrabber = true, | |
| required this.contentBuilder, | |
| }); | |
| final double topMargin; | |
| final double footerHeaderHeight; | |
| final double? contentHeight; | |
| final Color barrielColor; | |
| final Widget Function(BuildContext context, ScrollController controller) | |
| contentBuilder; | |
| final Widget? Function(BuildContext context, ScrollController controller)? | |
| footerBuilder; | |
| final Widget? Function(BuildContext context, ScrollController controller)? | |
| headerBuilder; | |
| final bool showGrabber; | |
| @override | |
| State<MyBottomSheet> createState() => _MyBottomSheetState(); | |
| } | |
| class _MyBottomSheetState extends State<MyBottomSheet> { | |
| // Screen height | |
| late final double _screenHeight = MediaQuery.sizeOf(context).height; | |
| // Minimum sheet height based only on header + footer | |
| late final double _minSheetHeight = | |
| 1 - (_screenHeight - widget.footerHeaderHeight) / _screenHeight; | |
| // Current sheet height, by default set to min height | |
| late double _sheetHeight = _minSheetHeight; | |
| // Measured height of the scrollable content (without header + footer) | |
| double? _measuredContentHeight; | |
| // Effective content height: | |
| // 1) explicit contentHeight (if provided) | |
| // 2) measured content height | |
| // 3) fall back to full screen | |
| double get _effectiveContentHeight => | |
| widget.contentHeight ?? _measuredContentHeight ?? _screenHeight; | |
| // Max sheet height as a fraction of the screen | |
| double get _maxSheetHeight { | |
| final availableHeight = _screenHeight - widget.topMargin; | |
| final desiredHeight = _effectiveContentHeight + widget.footerHeaderHeight; | |
| final totalHeight = desiredHeight < availableHeight | |
| ? desiredHeight | |
| : availableHeight; | |
| return totalHeight / _screenHeight; | |
| } | |
| double _calculateProgress() { | |
| // Normalized value between 0.0 (min) and 1.0 (max) | |
| final range = _maxSheetHeight - _minSheetHeight; | |
| if (range <= 0) return 0; | |
| final value = (_sheetHeight - _minSheetHeight) / range; | |
| return value.clamp(0.0, 1.0); | |
| } | |
| final DraggableScrollableController? _controller = | |
| DraggableScrollableController(); | |
| @override | |
| Widget build(BuildContext context) { | |
| final progress = _calculateProgress(); | |
| final barrierT = Curves.easeInOut.transform(progress); | |
| return Stack( | |
| alignment: Alignment.bottomCenter, | |
| children: [ | |
| IgnorePointer( | |
| // When fully transparent, let touches pass through. | |
| ignoring: barrierT == 0, | |
| child: GestureDetector( | |
| onTap: () { | |
| if (_controller == null) return; | |
| if (!_controller.isAttached) return; | |
| _controller.animateTo( | |
| _minSheetHeight, | |
| duration: Durations.short1, | |
| curve: Curves.easeInOut, | |
| ); | |
| }, | |
| child: AnimatedContainer( | |
| duration: Durations.short1, | |
| curve: Curves.easeInOut, | |
| width: double.infinity, | |
| height: _screenHeight, | |
| color: Color.lerp( | |
| Colors.transparent, | |
| widget.barrielColor, | |
| barrierT, | |
| )!, | |
| ), | |
| ), | |
| ), | |
| NotificationListener<DraggableScrollableNotification>( | |
| onNotification: (notification) { | |
| if (!mounted) return false; | |
| setState(() { | |
| _sheetHeight = notification.extent; | |
| }); | |
| return false; | |
| }, | |
| child: DraggableScrollableSheet( | |
| controller: _controller, | |
| shouldCloseOnMinExtent: false, | |
| // False because we want to adapt to child sizes | |
| expand: false, | |
| // Should be dynamic based on fixed min size vs the screen size | |
| minChildSize: _minSheetHeight, | |
| // Same that initialChildSize for fixed size | |
| initialChildSize: _sheetHeight, | |
| maxChildSize: _maxSheetHeight, | |
| builder: (context, scrollController) { | |
| // Measure the scrollable content height once it has clients | |
| if (_measuredContentHeight == null && | |
| scrollController.hasClients) { | |
| WidgetsBinding.instance.addPostFrameCallback((_) { | |
| if (!mounted || !scrollController.hasClients) return; | |
| final position = scrollController.position; | |
| // Total scrollable content height | |
| final contentHeight = | |
| position.viewportDimension + position.maxScrollExtent; | |
| if (_measuredContentHeight == contentHeight) return; | |
| setState(() { | |
| _measuredContentHeight = contentHeight; | |
| // Ensure current sheet height stays within new bounds | |
| _sheetHeight = _sheetHeight.clamp( | |
| _minSheetHeight, | |
| _maxSheetHeight, | |
| ); | |
| _controller?.jumpTo(_sheetHeight); | |
| }); | |
| }); | |
| } | |
| /// Fixed Header | |
| /// Scrollable Content | |
| /// Fixed Footer | |
| return Container( | |
| decoration: BoxDecoration( | |
| color: Colors.red.shade100, | |
| borderRadius: const BorderRadius.only( | |
| topLeft: Radius.circular(16.0), | |
| topRight: Radius.circular(16.0), | |
| ), | |
| ), | |
| width: double.infinity, | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| // Fixed Header (drag handle + title) | |
| if (widget.headerBuilder != null || widget.showGrabber) | |
| Grabber( | |
| showGrabber: widget.showGrabber, | |
| onVerticalDragUpdate: (DragUpdateDetails details) { | |
| setState(() { | |
| _sheetHeight -= (details.delta.dy / 600); | |
| _sheetHeight = _sheetHeight.clamp( | |
| _minSheetHeight, | |
| _maxSheetHeight, | |
| ); | |
| }); | |
| if (!(_controller?.isAttached ?? false)) return; | |
| _controller?.jumpTo(_sheetHeight); | |
| }, | |
| child: widget.headerBuilder != null | |
| ? widget.headerBuilder!(context, scrollController)! | |
| : null, | |
| ), | |
| // Scrollable or Non-Scrollable Content | |
| Flexible( | |
| child: widget.contentBuilder(context, scrollController), | |
| ), | |
| // Footer | |
| if (widget.footerBuilder != null) | |
| GestureDetector( | |
| onVerticalDragUpdate: (DragUpdateDetails details) { | |
| setState(() { | |
| _sheetHeight -= (details.delta.dy / 600); | |
| _sheetHeight = _sheetHeight.clamp( | |
| _minSheetHeight, | |
| _maxSheetHeight, | |
| ); | |
| }); | |
| if (!(_controller?.isAttached ?? false)) return; | |
| _controller?.jumpTo(_sheetHeight); | |
| }, | |
| child: widget.footerBuilder!( | |
| context, | |
| scrollController, | |
| )!, | |
| ), | |
| ], | |
| ), | |
| ); | |
| }, | |
| ), | |
| ), | |
| ], | |
| ); | |
| } | |
| } | |
| /// A draggable widget that accepts vertical drag gestures | |
| /// and this is only visible on desktop and web platforms. | |
| class Grabber extends StatelessWidget { | |
| const Grabber({ | |
| super.key, | |
| required this.onVerticalDragUpdate, | |
| this.child, | |
| required this.showGrabber, | |
| }); | |
| final ValueChanged<DragUpdateDetails> onVerticalDragUpdate; | |
| final Widget? child; | |
| final bool showGrabber; | |
| @override | |
| Widget build(BuildContext context) => GestureDetector( | |
| onVerticalDragUpdate: onVerticalDragUpdate, | |
| child: Container( | |
| width: double.infinity, | |
| decoration: BoxDecoration( | |
| color: Colors.red.shade50, | |
| borderRadius: const BorderRadius.only( | |
| topLeft: Radius.circular(16.0), | |
| topRight: Radius.circular(16.0), | |
| ), | |
| ), | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| if (showGrabber) | |
| Align( | |
| alignment: Alignment.topCenter, | |
| child: Container( | |
| margin: const EdgeInsets.symmetric(vertical: 8.0), | |
| width: 32.0, | |
| height: 4.0, | |
| decoration: BoxDecoration( | |
| color: Colors.grey.shade900, | |
| borderRadius: BorderRadius.circular(8.0), | |
| ), | |
| ), | |
| ), | |
| if (child != null) child!, | |
| ], | |
| ), | |
| ), | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment