Skip to content

Instantly share code, notes, and snippets.

@hectorAguero
Last active November 17, 2025 12:33
Show Gist options
  • Select an option

  • Save hectorAguero/5527303dd58a60d69bd0d0ce6e581c73 to your computer and use it in GitHub Desktop.

Select an option

Save hectorAguero/5527303dd58a60d69bd0d0ce6e581c73 to your computer and use it in GitHub Desktop.
DraggableScrollableSheet with Footer and Header
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