0

I am trying find a way for the user to Scroll Parent Widget when Child Widgets have been scrolled to the top or bottom, whilst maintaining the scroll velocity / scroll momentum / scroll physics / user experience.

A good demonstration of what I'm trying to achieve (albeit without BouncingScrollPhysics): https://i.stack.imgur.com/qpfNP.jpg Taken from: Flutter: Continue scrolling in top Listview when reaching bottom of nested Listview

I appreciate lots of similar questions to this have been asked already, with answers relating to NotificationListener, which I have tried. However this method does not maintain the scroll velocity / scroll momentum / scroll physics / user experience, so leads to a poor quality user experience. Flutter: Continue scrolling in top Listview when reaching bottom of nested Listview looks to use a different method that might achieve the desired results, but I'm unable to get it to work correctly / with allowed operation conditions).

It seems odd there is not yet an answer to fully satisfies the desired functionality as it is very common on websites. It's clear by the number of questions on this topic, a full solution would be really appreciated.

Best Q&As so far:

Other similar Q&As:

I have created some basic code that can be used to test / demonstrate solutions that 'everyone' should be able to understand easily:

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final ScrollController _textAController = ScrollController();
  final ScrollController _textBController = ScrollController();
  final ScrollController _pageController = ScrollController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: ListView(
        controller: _pageController,
        children: [
          Container(
            height: 200,
            color: Colors.green,
          ),
          Container(
            height: 200,
            color: Colors.red,
          ),
          Padding(
            padding: const EdgeInsets.only(bottom: 8.0),
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxHeight: 200),
              child: SingleChildScrollView(
                padding: const EdgeInsets.only(right: 15),
                controller: _textAController,
                physics: const ClampingScrollPhysics(),
                child: Column(
                  children: [
                    const Text(
                      'Scrollable Child 1',
                      softWrap: true,
                      textAlign: TextAlign.center,
                    ),
                    Container(
                      color: Colors.amber,
                      height: 600,
                    )
                  ],
                ),
              ),
            ),
          ),
          Container(
            height: 10,
            color: Colors.purple,
          ),
          Padding(
            padding: const EdgeInsets.only(top: 8.0),
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxHeight: 200),
              child: SingleChildScrollView(
                controller: _textBController,
                physics: const ClampingScrollPhysics(),
                padding: const EdgeInsets.only(right: 15),
                child: Column(
                  children: [
                    const Text(
                      'Scrollable Child 2',
                      softWrap: true,
                      textAlign: TextAlign.center,
                    ),
                    ListView.separated(
                      physics: const NeverScrollableScrollPhysics(),
                      separatorBuilder: (BuildContext context, index) {
                        return Container(
                          height: 12,
                          width: 50,
                          color: Colors.grey,
                        );
                      },
                      shrinkWrap: true,
                      padding: const EdgeInsets.fromLTRB(0, 0, 0, 0),
                      itemCount: 5,
                      itemBuilder: (BuildContext context, int index) {
                        return Container(
                          height: 60,
                          color: Colors.black,
                        );
                      },
                    ),
                  ],
                ),
              ),
            ),
          ),
          Container(
            height: 100,
            color: Colors.blue,
          ),
        ],
      ),
    );
  }
}
RedHappyLlama
  • 53
  • 1
  • 8

1 Answers1

0

I was having the same problem until I came across your post and your links to some of the other posts trying to solve the same problem. So thanks for pointing me in the right direction. The key to it is using the velocity data provided by some of the scroll events you can listen to with the NotificationListener:

My solution is a little hackier than i'd ideally like, but the behavior is what you're after I believe.

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final ScrollController _textAController = ScrollController();
  final ScrollController _textBController = ScrollController();
  final ScrollController _pageController = ScrollController();
  bool _scrolling = false;

  getMinMaxPosition(double tryScrollTo){
    return 
      tryScrollTo < _pageController.position.minScrollExtent 
        ? _pageController.position.minScrollExtent 
        : tryScrollTo > _pageController.position.maxScrollExtent 
            ? _pageController.position.maxScrollExtent 
            : tryScrollTo;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: 
        NotificationListener(
          onNotification: (ScrollNotification notification) {
            //users finger is still on the screen
            if(notification is OverscrollNotification && notification.velocity == 0){
              var scrollTo = getMinMaxPosition(_pageController.position.pixels + (notification.overscroll));
              _pageController.jumpTo(scrollTo);
            }
            //users finger left screen before limit of the listview was reached, but momentum takes it to the limit and beoyond
            else if(notification is OverscrollNotification){
              var yVelocity = notification.velocity;
              _scrolling = true;//stops other notifiations overriding this scroll animation
              var scrollTo = getMinMaxPosition(_pageController.position.pixels + (yVelocity/5));
              _pageController.animateTo(scrollTo, duration: const Duration(milliseconds: 1000),curve: Curves.linearToEaseOut).then((value) => _scrolling = false);
            }
            //users finger left screen after the limit of teh list view was reached
            else if(notification is ScrollEndNotification && notification.depth > 0 && !_scrolling){
              var yVelocity = notification.dragDetails?.velocity.pixelsPerSecond.dy ?? 0;
              var scrollTo = getMinMaxPosition(_pageController.position.pixels - (yVelocity/5));
              var scrollToPractical = scrollTo < _pageController.position.minScrollExtent ? _pageController.position.minScrollExtent : scrollTo > _pageController.position.maxScrollExtent ? _pageController.position.maxScrollExtent : scrollTo;
              _pageController.animateTo(scrollToPractical, duration: const Duration(milliseconds: 1000),curve: Curves.linearToEaseOut); 
            }
            return true;
          },
      child: ListView(
        controller: _pageController,
        children: [
          Container(
            height: 200,
            color: Colors.green,
          ),
          Container(
            height: 200,
            color: Colors.red,
          ),
          Padding(
            padding: const EdgeInsets.only(bottom: 8.0),
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxHeight: 200),
              child: SingleChildScrollView(
                padding: const EdgeInsets.only(right: 15),
                controller: _textAController,
                physics: const ClampingScrollPhysics(),
                child: Column(
                  children: [
                    const Text(
                      'Scrollable Child 1',
                      softWrap: true,
                      textAlign: TextAlign.center,
                    ),
                    Container(
                      color: Colors.amber,
                      height: 600,
                    )
                  ],
                ),
              ),
            ),
          ),
          Container(
            height: 10,
            color: Colors.purple,
          ),
          Padding(
            padding: const EdgeInsets.only(top: 8.0),
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxHeight: 200),
              child: SingleChildScrollView(
                controller: _textBController,
                physics: const ClampingScrollPhysics(),
                padding: const EdgeInsets.only(right: 15),
                child: Column(
                  children: [
                    const Text(
                      'Scrollable Child 2',
                      softWrap: true,
                      textAlign: TextAlign.center,
                    ),
                    ListView.separated(
                      physics: const NeverScrollableScrollPhysics(),
                      separatorBuilder: (BuildContext context, index) {
                        return Container(
                          height: 12,
                          width: 50,
                          color: Colors.grey,
                        );
                      },
                      shrinkWrap: true,
                      padding: const EdgeInsets.fromLTRB(0, 0, 0, 0),
                      itemCount: 5,
                      itemBuilder: (BuildContext context, int index) {
                        return Container(
                          height: 60,
                          color: Colors.black,
                        );
                      },
                    ),
                  ],
                ),
              ),
            ),
          ),
          Container(
            height: 100,
            color: Colors.blue,
          ),
        ],
      )),
    );
  }
}
mokes
  • 1