0

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!

2
  • Have two flex containers, and slowly change the flex numbers for them (in state) based on scroll position, and call setState. Commented Jan 25, 2023 at 4:43
  • Added an animation of how it should work: share.cleanshot.com/mnZhJF8x Commented Jan 27, 2023 at 17:01

3 Answers 3

2

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"));
              }),
        ),
      ),
    ],
  ),
Sign up to request clarification or add additional context in comments.

3 Comments

Does this resize the ListView while scrolling? I don't see that implemented in your answer.
It should look like this: share.cleanshot.com/mnZhJF8x
yes, you can scroll controller for both as your requirements.
1

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.

Comments

0

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

4 Comments

Thank you for this answer but it's not quite what I was looking for. The resizing should be consistent and smooth with the dragging action so that the user feels as if they are actually dragging the list and the list should stay expanded after they stop dragging. In the example you gave, the list immediately snaps to larger size and then snaps back as soon as scrolling is paused. It should instead smoothly resize with the scroll and stay expanded until user scrolls to the end in opposite direction. I hope that clarifies what I'm asking.
It should look like this: share.cleanshot.com/mnZhJF8x
People takes time to answer questions. Your question was vague to begin with. But after you posted your screenshot and i wasted time to find a solution for you. On the other hand, you did not hesitate to put negative marking on my answer. It's people like you who demotivates other to answer a question. I should've avoided your vague question altogether.
I did not mark your question negative. There are more people on stackoverflow than just the two of us. I appreciate your answer and the time you took you answer.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.