skip to Main Content

I’m trying to build a screen where two vertically stacked ListViews cause themselves to grow and shrink as a result of being scrolled. Here is an illustration:

enter image description here

The initial state is that both lists take up 50% of the top and bottom of the screen respectively. When the user starts dragging the top list downward (to scroll up) it will initially cause the list to expand to take up 75% of the screen before the normal scrolling behavior starts; when the user changes direction, dragging upwards (to scroll down), then as they get to the bottom of the list it will cause the list to shrink back up to only taking up 50% of the screen (the initial state).

The bottom list would work similarly, dragging up would cause the list to expand upwards to take up 75% of the screen before the normal scrolling behavior starts; when the user changes direction, dragging downwards (to scroll up), then as they get to the top of the list it will shrink back to 50% of the screen.

Here is an animation of what it should look like:
https://share.cleanshot.com/mnZhJF8x

My question is, what is the best widget combination to implement this and how do I tie the scrolling events with resizing the ListViews?

So far, this is as far as I’ve gotten:

Column(
  children: [
    SizedBox(
      height: availableHeight / 2,
      child: ListView(...)
    ),
    Expanded(child: ListView(...)),
  ],
),

In terms of similar behavior, it appears that the CustomScrollView and SliverAppBar have some of the elements in scrolling behaving I’m going after but it’s not obvious to me how to convert that into the the two adjacent lists view I described above.

Any advice would be greatly appreciated, thank you!

3

Answers


  1. hi Check this,

      Column(
        children: [
          Expanded ( 
          flex:7,
            child: Container(
    
              child: ListView.builder(
                  itemCount:50,
                  itemBuilder: (BuildContext context, int index) {
                    return ListTile(
                        leading: const Icon(Icons.list),
                        trailing: const Text(
                          "GFG",
                          style: TextStyle(color: Colors.green, fontSize: 15),
                        ),
                        title: Text("List item $index"));
                  }),
            ),
          ),
          Expanded ( 
          flex:3,
            child: Container(
              child: ListView.builder(
                  itemCount:50,
                  itemBuilder: (BuildContext context, int index) {
                    return ListTile(
                        leading: const Icon(Icons.list),
                        trailing: const Text(
                          "GFG",
                          style: TextStyle(color: Colors.green, fontSize: 15),
                        ),
                        title: Text("aaaaaaaaa $index"));
                  }),
            ),
          ),
        ],
      ),
    
    Login or Signup to reply.
  2. First, initialise two scroll controllers for two of your listviews. Then register a post-frame callback by using WidgetsBinding.instance.addPostFrameCallback to make sure that the scroll controller has been linked to a scroll view. Next, setup scroll listeners in that callback.

    To listen to scrolling update you can use scrollController.addListener. Then use if-else cases to catch the position of the scroll, if scroll position equals to maxScrollExtent then the user scrolled bottom and its the other way round for minScrollExtent. Check my edited implementation below:

    class HomeScreen extends StatefulWidget {
      const HomeScreen({Key? key}) : super(key: key);
    
      @override
      State<HomeScreen> createState() => _HomeScreenState();
    }
    
    class _HomeScreenState extends State<HomeScreen> {
      final ScrollController _scrollCtrl1 = ScrollController();
      final ScrollController _scrollCtrl2 = ScrollController();
      double height1 = 300;
      double height2 = 300;
      bool isLoading = true;
    
      @override
      void initState() {
        WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
          setState(() {
            isLoading = false;
            height1 = SizeConfig.blockSizeVertical! * 50;
            height2 = SizeConfig.blockSizeVertical! * 50;
          });
          _scrollCtrl1.addListener(() {
            if (_scrollCtrl1.position.pixels == _scrollCtrl1.position.maxScrollExtent) {
              setState(() {
                height1 = SizeConfig.blockSizeVertical! * 25;
                height2 = SizeConfig.blockSizeVertical! * 75;
              });
            }
            if (_scrollCtrl1.position.pixels == _scrollCtrl1.position.minScrollExtent) {
              setState(() {
                height1 = SizeConfig.blockSizeVertical! * 75;
                height2 = SizeConfig.blockSizeVertical! * 25;
              });
            }
          });
    
          _scrollCtrl2.addListener(() {
            if (_scrollCtrl2.position.pixels == _scrollCtrl2.position.maxScrollExtent) {
              setState(() {
                height1 = SizeConfig.blockSizeVertical! * 25;
                height2 = SizeConfig.blockSizeVertical! * 75;
    
              });
            }
            if (_scrollCtrl2.position.pixels == _scrollCtrl2.position.minScrollExtent) {
              setState(() {
                height1 = SizeConfig.blockSizeVertical! * 75;
                height2 = SizeConfig.blockSizeVertical! * 25;
    
              });
            }
          });
    
        });
        super.initState();
      }
    
      @override
      Widget build(BuildContext context) {
        SizeConfig().init(context);
    
        return Scaffold(
          body: !isLoading ? Column(
            children: [
              AnimatedContainer(
                color: Colors.blueGrey,
                height: height1,
                duration: const Duration(seconds: 1),
                curve: Curves.fastOutSlowIn,
                child: ListView.builder(
                    itemCount: 50,
                    padding: EdgeInsets.zero,
                    controller: _scrollCtrl1,
                    itemBuilder: (BuildContext context, int index) {
                      return ListTile(
                          leading: const Icon(Icons.list),
                          dense: true,
                          trailing: const Text(
                            "GFG",
                            style: TextStyle(color: Colors.green, fontSize: 15),
                          ),
                          title: Text("List item $index"));
                    }),
              ),
    
              AnimatedContainer(
                height: height2,
                color: Colors.deepPurpleAccent,
                duration: const Duration(seconds: 1),
                curve: Curves.fastOutSlowIn,
                child: ListView.builder(
                    itemCount: 50,
                    padding: EdgeInsets.zero,
                    controller: _scrollCtrl2,
                    itemBuilder: (BuildContext context, int index) {
                      return ListTile(
                          dense: true,
                          leading: const Icon(Icons.list),
                          trailing: const Text(
                            "GFG",
                            style: TextStyle(color: Colors.green, fontSize: 15),
                          ),
                          title: Text("aaaaaaaaa $index"));
                    }),
              ),
            ],
          ) : const Center(child: CircularProgressIndicator(),),
        );
      }
    }
    
    class SizeConfig {
    
      static MediaQueryData? _mediaQueryData;
      static double? screenWidth;
      static double? screenHeight;
      static double? blockSizeHorizontal;
      static double? blockSizeVertical;
    
    
      /// This class measures the screen height & width.
      /// Remember: Always call the init method at the start of your application or in main
      void init(BuildContext? context) {
        _mediaQueryData = MediaQuery.of(context!);
        screenWidth = _mediaQueryData?.size.width;
        screenHeight = _mediaQueryData?.size.height;
        blockSizeHorizontal = (screenWidth! / 100);
        blockSizeVertical = (screenHeight! / 100);
      }
    }
    

    Example

    Login or Signup to reply.
  3. edit: refactored and maybe better version:

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'ExtentableTwoRowScrollable Demo',
          home: Scaffold(
            body: LayoutBuilder(
                builder: (BuildContext context, BoxConstraints constraints) {
              return ExtentableTwoRowScrollable(
                height: constraints.maxHeight,
              );
            }),
          ),
        );
      }
    }
    
    // sorry for the name :)
    class ExtentableTwoRowScrollable extends StatefulWidget {
      const ExtentableTwoRowScrollable({
        super.key,
        required this.height,
        this.minHeight = 150.0,
      });
      final double height;
      final double minHeight;
    
      @override
      State<ExtentableTwoRowScrollable> createState() =>
          _ExtentableTwoRowScrollableState();
    }
    
    class _ExtentableTwoRowScrollableState extends State<ExtentableTwoRowScrollable>
        with SingleTickerProviderStateMixin {
      final upperSizeNotifier = ValueNotifier(0.0);
      final lowerSizeNotifier = ValueNotifier(0.0);
      var upperHeight = 0.0;
      var dragOnUpper = true;
    
      void incrementNotifier(ValueNotifier notifier, double increment) {
        if (notifier.value + increment >= widget.height - widget.minHeight) return;
        if (notifier.value + increment < widget.minHeight) return;
        notifier.value += increment;
      }
    
      bool handleVerticalDrag(ScrollNotification notification) {
        if (notification is ScrollStartNotification &&
            notification.dragDetails != null) {
          if (notification.dragDetails!.globalPosition.dy <
              upperSizeNotifier.value) {
            dragOnUpper = true;
          } else {
            dragOnUpper = false;
          }
        }
        if (notification is ScrollUpdateNotification) {
          final delta = notification.scrollDelta ?? 0.0;
          if (dragOnUpper) {
            if (notification.metrics.extentAfter != 0) {
              incrementNotifier(upperSizeNotifier, delta.abs());
              incrementNotifier(lowerSizeNotifier, -1 * delta.abs());
            } else {
              incrementNotifier(upperSizeNotifier, -1 * delta.abs());
              incrementNotifier(lowerSizeNotifier, delta.abs());
            }
          }
          if (!dragOnUpper) {
            if (notification.metrics.extentBefore != 0) {
              incrementNotifier(upperSizeNotifier, -1 * delta.abs());
              incrementNotifier(lowerSizeNotifier, delta.abs());
            } else {
              incrementNotifier(upperSizeNotifier, delta.abs());
              incrementNotifier(lowerSizeNotifier, -1 * delta.abs());
            }
          }
        }
    
        return true;
      }
    
      @override
      Widget build(BuildContext context) {
        // initialize ratio of lower and upper, f.e. here 50:50
        upperSizeNotifier.value = widget.height / 2;
        lowerSizeNotifier.value = widget.height / 2;
        return NotificationListener(
          onNotification: handleVerticalDrag,
          child: Column(
            children: [
              ValueListenableBuilder<double>(
                valueListenable: upperSizeNotifier,
                builder: (context, value, child) {
                  return Container(
                    color: Colors.greenAccent,
                    height: value,
                    child: ListView.builder(
                      shrinkWrap: true,
                      itemCount: 40,
                      itemBuilder: (BuildContext context, int index) {
                        return ListTile(
                            leading: const Icon(Icons.list),
                            title: Text("upper ListView $index"));
                      },
                    ),
                  );
                },
              ),
              ValueListenableBuilder<double>(
                valueListenable: lowerSizeNotifier,
                builder: (context, value, child) {
                  return Container(
                    color: Colors.blueGrey,
                    height: value,
                    child: ListView.builder(
                      shrinkWrap: true,
                      itemCount: 40,
                      itemBuilder: (BuildContext context, int index) {
                        return ListTile(
                            leading: const Icon(Icons.list),
                            title: Text("lower ListView $index"));
                      },
                    ),
                  );
                },
              ),
            ],
          ),
        );
      }
    }
    

    here is the older post:
    so, here’s my shot on this. There might be a less complicated solution of course but I think it’s somewhat understandable. At least I’ve tried to comment good enough.

    Let me know if it works for you.

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'ExtentableTwoRowScrollable Demo',
          home: Scaffold(
            body: LayoutBuilder(
                builder: (BuildContext context, BoxConstraints constraints) {
              return ExtentableTwoRowScrollable(
                height: constraints.maxHeight,
              );
            }),
          ),
        );
      }
    }
    
    // sorry for the name :)
    class ExtentableTwoRowScrollable extends StatefulWidget {
      const ExtentableTwoRowScrollable({
        super.key,
        required this.height,
        this.minHeightUpper = 300.0,
        this.minHeightLower = 300.0,
      });
      final double height;
      final double minHeightUpper;
      final double minHeightLower;
    
      @override
      State<ExtentableTwoRowScrollable> createState() =>
          _ExtentableTwoRowScrollableState();
    }
    
    class _ExtentableTwoRowScrollableState extends State<ExtentableTwoRowScrollable>
        with SingleTickerProviderStateMixin {
      final upperSizeNotifier = ValueNotifier(0.0);
      final lowerSizeNotifier = ValueNotifier(0.0);
      var upperHeight = 0.0;
      var dragOnUpper = true;
    
      bool handleVerticalDrag(ScrollNotification notification) {
        if (notification is ScrollStartNotification &&
            notification.dragDetails != null)
        // only act on ScrollStartNotification events with dragDetails
        {
          if (notification.dragDetails!.globalPosition.dy <
              upperSizeNotifier.value) {
            dragOnUpper = true;
          } else {
            dragOnUpper = false;
          }
        }
        if (notification is ScrollUpdateNotification &&
            notification.dragDetails != null)
        // only act on ScrollUpdateNotification events with dragDetails
        {
          if (dragOnUpper) {
            // dragging is going on, was started on upper ListView
            if (notification.dragDetails!.delta.direction > 0)
            // dragging backward/downwards
            {
              if (lowerSizeNotifier.value >= widget.minHeightLower)
              // expand upper until minHeightLower gets hit
              {
                lowerSizeNotifier.value -= notification.dragDetails!.delta.distance;
                upperSizeNotifier.value += notification.dragDetails!.delta.distance;
              }
            } else
            // dragging forward/upwards
            {
              if (notification.metrics.extentAfter == 0.0 &&
                  upperSizeNotifier.value > widget.minHeightUpper)
              // when at the end of upper shrink it until minHeightUpper gets hit
              {
                lowerSizeNotifier.value += notification.dragDetails!.delta.distance;
                upperSizeNotifier.value -= notification.dragDetails!.delta.distance;
              }
            }
          }
          if (!dragOnUpper) {
            // dragging is going on, was started on lower ListView
            if (notification.dragDetails!.delta.direction > 0)
            // dragging backward/downwards
            {
              if (notification.metrics.extentBefore == 0.0 &&
                  lowerSizeNotifier.value > widget.minHeightLower)
              // when at the top of lower shrink it until minHeightLower gets hit
              {
                lowerSizeNotifier.value -= notification.dragDetails!.delta.distance;
                upperSizeNotifier.value += notification.dragDetails!.delta.distance;
              }
            } else
            // dragging forward/upwards
            {
              if (upperSizeNotifier.value >= widget.minHeightUpper)
              // expand lower until minHeightUpper gets hit
              {
                lowerSizeNotifier.value += notification.dragDetails!.delta.distance;
                upperSizeNotifier.value -= notification.dragDetails!.delta.distance;
              }
            }
          }
        }
        return true;
      }
    
      @override
      Widget build(BuildContext context) {
        // initialize ratio of lower and upper, f.e. here 50:50
        upperSizeNotifier.value = widget.height / 2;
        lowerSizeNotifier.value = widget.height / 2;
        return NotificationListener(
          onNotification: handleVerticalDrag,
          child: Column(
            children: [
              ValueListenableBuilder<double>(
                valueListenable: upperSizeNotifier,
                builder: (context, value, child) {
                  return Container(
                    color: Colors.greenAccent,
                    height: value,
                    child: ListView.builder(
                      shrinkWrap: true,
                      itemCount: 40,
                      itemBuilder: (BuildContext context, int index) {
                        return ListTile(
                            leading: const Icon(Icons.list),
                            title: Text("upper ListView $index"));
                      },
                    ),
                  );
                },
              ),
              ValueListenableBuilder<double>(
                valueListenable: lowerSizeNotifier,
                builder: (context, value, child) {
                  return Container(
                    color: Colors.blueGrey,
                    height: value,
                    child: ListView.builder(
                      shrinkWrap: true,
                      itemCount: 40,
                      itemBuilder: (BuildContext context, int index) {
                        return ListTile(
                            leading: const Icon(Icons.list),
                            title: Text("lower ListView $index"));
                      },
                    ),
                  );
                },
              ),
            ],
          ),
        );
      }
    }
    

    I think it’s working okayish so far but supporting the "fling" effect – I mean the acceleration when users shoot the scrollable until simulated physics slows it down again – would be really nice, too.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search