fluttertabviewflutter-showmodalbottomsheet

Flutter dynamic height in draggable scrollable sheet


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"),
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}


Solution

  • 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;
    }