I'm having trouble displaying a TabView inside a DraggableScrollableSheet
in Flutter.
Below is the code. I don’t want to use a SizedBox
with a fixed height like 500, because the height should adjust dynamically based on the device.
However, if I don’t wrap the TabView
in a SizedBox
, Flutter throws an error about missing height constraints.
Is there a way to set a dynamic height for the SizedBox
that works across different devices?
Also, I want the bottom sheet to be scrollable, and each tab inside the TabView
should also be independently scrollable. But I don’t want scrolling to hide the "Starbucks" title at the top when I scroll to the top.
import 'package:flutter/material.dart';
class BottomSheetPage extends StatefulWidget {
const BottomSheetPage({super.key});
@override
State<BottomSheetPage> createState() => _BottomSheetPageState();
}
class _BottomSheetPageState extends State<BottomSheetPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final List<String> items = List.generate(25, (index) => 'Item ${index + 1}');
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.grey,
title: const Text(
"Bottom Sheet Test Page",
style: TextStyle(
color: Colors.white,
),
),
),
body: const Center(
child: Text("This is Main Page"),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_showBottomSheetStarbucksTest();
},
child: const Icon(Icons.add),
),
);
}
Widget makeDismissible({required Widget child}) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.of(context).pop(),
child: GestureDetector(onTap: () {}, child: child),
);
void _showBottomSheetStarbucksTest() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
enableDrag: false,
isDismissible: false,
backgroundColor: Colors.transparent,
builder: (context) => _getDraggableSheet(),
);
}
Widget _getDraggableSheet() {
return makeDismissible(
child: DraggableScrollableSheet(
initialChildSize: 0.5,
minChildSize: 0.1,
maxChildSize: 0.9,
shouldCloseOnMinExtent: false,
builder: (_, scrollableController) => Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(20),
),
),
padding: const EdgeInsets.all(10),
child: ListView(
controller: scrollableController,
children: [
const Text(
"Starbucks",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
Row(
children: List.generate(
5,
(index) {
return Icon(
index < 4 ? Icons.star : Icons.star_half,
color: Colors.amber,
);
},
),
),
const Text(
'4.3',
style: TextStyle(fontSize: 18),
),
],
),
const SizedBox(height: 8),
const Text("Coffee shop - \$10-\$20 - 6 min walk"),
const SizedBox(height: 8),
SizedBox(
height: 150,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
Row(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
'https://github.com/yavuzceliker/sample-images/blob/main/images/image-100.jpg?raw=true',
fit: BoxFit.cover,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 0, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
'https://github.com/yavuzceliker/sample-images/blob/main/images/image-101.jpg?raw=true',
fit: BoxFit.cover,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 0, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
'https://github.com/yavuzceliker/sample-images/blob/main/images/image-103.jpg?raw=true',
fit: BoxFit.cover,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 0, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
'https://github.com/yavuzceliker/sample-images/blob/main/images/image-104.jpg?raw=true',
fit: BoxFit.cover,
),
),
),
],
),
],
),
),
const SizedBox(height: 8),
TabBar(
controller: _tabController,
tabs: const [
Tab(text: "Expense"),
Tab(text: "Photo"),
Tab(text: "Review"),
],
),
SizedBox(
height: 500,
child: TabBarView(
controller: _tabController,
children: [
ListView.builder(
itemCount: 25,
shrinkWrap: true,
controller: scrollableController,
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.label),
title: Text(items[index]),
subtitle: Text('Subtitle for ${items[index]}'),
trailing: const Icon(Icons.arrow_forward),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${items[index]} clicked')),
);
},
);
},
),
const Center(
child: Text("Photo Gallery"),
),
const Center(
child: Text("User Reviews"),
),
],
),
),
],
),
),
),
);
}
}
TabView dynamic size and scrollable ✅
Pinned Title widget ✅
No weird scrolling behavior when drag bottomsheet (size change) ✅
I'm fix your code a little bit by using CustomScrollView
to pinned title widget (SliverPersistentHeader) then use StatefulBuilder to make bottomsheet can rebuild when setState if you look in the listener you will see I can get bottomsheet size and I already know title widget height (Define in _CustomHraderDelegate) so TabView size should be BottomSheetHeight - TitleWidgetHeight
import 'package:flutter/material.dart';
class BottomSheetPage extends StatefulWidget {
const BottomSheetPage({super.key});
@override
State<BottomSheetPage> createState() => _BottomSheetPageState();
}
class _BottomSheetPageState extends State<BottomSheetPage> with SingleTickerProviderStateMixin {
late TabController _tabController;
DraggableScrollableController _dragController = DraggableScrollableController();
final List<String> items = List.generate(25, (index) => 'Item ${index + 1}');
final imageUrls = [
'https://github.com/yavuzceliker/sample-images/blob/main/images/image-100.jpg?raw=true',
'https://github.com/yavuzceliker/sample-images/blob/main/images/image-101.jpg?raw=true',
'https://github.com/yavuzceliker/sample-images/blob/main/images/image-103.jpg?raw=true',
'https://github.com/yavuzceliker/sample-images/blob/main/images/image-104.jpg?raw=true',
];
var currentHeight = 0.0;
var hasListener = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.grey,
title: const Text(
"Bottom Sheet Test Page",
style: TextStyle(
color: Colors.white,
),
),
),
body: const Center(
child: Text("This is Main Page"),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_showBottomSheetStarbucksTest();
},
child: const Icon(Icons.add),
),
);
}
Widget makeDismissible({required Widget child}) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
_dragController.dispose();
_dragController = DraggableScrollableController();
hasListener = false;
Navigator.of(context).pop();
},
child: GestureDetector(onTap: () {}, child: child),
);
void _showBottomSheetStarbucksTest() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
enableDrag: false,
isDismissible: false,
backgroundColor: Colors.transparent,
builder: (context) => StatefulBuilder(
builder: (context, setStateBottomSheet) {
listener() {
final extent = _dragController.size;
setStateBottomSheet(() {
final screenHeight = MediaQuery.of(context).size.height;
currentHeight = extent * screenHeight;
});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!hasListener) {
_dragController.addListener(listener);
setStateBottomSheet(
// initial child size is 0.75
() => currentHeight = MediaQuery.of(context).size.height * 0.75,
);
}
hasListener = true;
});
return _getDraggableSheet();
},
),
);
}
Widget _getDraggableSheet() {
return makeDismissible(
child: DraggableScrollableSheet(
controller: _dragController,
initialChildSize: 0.75,
minChildSize: (110 / MediaQuery.of(context).size.height),
maxChildSize: 0.9,
shouldCloseOnMinExtent: false,
builder: (_, scrollableController) => Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(20),
),
),
padding: const EdgeInsets.all(10),
child: CustomScrollView(
controller: scrollableController,
shrinkWrap: true,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _CustomHeaderDelegate(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Starbucks",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
...List.generate(5, (index) {
return Icon(
index < 4 ? Icons.star : Icons.star_half,
color: Colors.amber,
);
}),
const Text('4.3', style: TextStyle(fontSize: 18)),
],
),
const SizedBox(height: 8),
const Text("Coffee shop - \$10-\$20 - 6 min walk"),
const SizedBox(height: 16),
SizedBox(
height: 150,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: imageUrls.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 8, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
imageUrls[index],
fit: BoxFit.cover,
),
));
},
)),
const SizedBox(height: 8),
TabBar(
controller: _tabController,
tabs: const [
Tab(text: "Expense"),
Tab(text: "Photo"),
Tab(text: "Review"),
],
),
],
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: currentHeight > 300 ? currentHeight - 300 : 0,
child: TabBarView(
controller: _tabController,
children: [
SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...List.generate(
25,
(index) => ListTile(
leading: const Icon(Icons.label),
title: Text(items[index]),
subtitle: Text('Subtitle for ${items[index]}'),
trailing: const Icon(Icons.arrow_forward),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${items[index]} clicked')),
);
},
),
),
],
),
),
const Center(
child: Text("Photo Gallery"),
),
const Center(
child: Text("User Reviews"),
),
],
)),
)
],
),
),
),
);
}
}
class _CustomHeaderDelegate extends SliverPersistentHeaderDelegate {
final Widget child;
_CustomHeaderDelegate({required this.child});
@override
double get minExtent => 300;
@override
double get maxExtent => 300;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
color: Colors.white,
child: child,
);
}
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => true;
}