skip to Main Content

scroll position of listview changed if I add multiple elements from top in list. It’s working fine for insert operation when we inster new elements in the bottom of the list.

Usecase is there is one chat module in my application & I have to implement both side pagination (up & down) in that. If user scroll up then normal pagination flow, items added in the bottom of the list so it’s working fine. But if user user scroll down then new items added at the top of the list & scroll position changed.

I have searched in all place & tried all solution but didn’t found any proper solution & many people also faced the same issue.

I am attaching a one dartpad link of this issue : open dartpad

Step to reproduce:

  • run the app, scroll to end of the list

  • now click on add icon, it will add 30 items in top of the list & you will notice scroll position will change after that

  • in this example I’m using setState but the same thing will happen even after using any state management solution.

  • I’m expecting not to change scroll position if I add elements from top of the list

2

Answers


  1. you just need one more function called scrollTop that needs to call inside _incrementCounter function

    void scrollTop() {
        controller.animateTo(
          0,
          duration: const Duration(milliseconds: 500),
          curve: Curves.easeOut,
        );
      }
    

    Demo:

    Below is fixed your code example:

    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> {
      List<String> listItem = [];
      ScrollController controller = ScrollController();
    
      @override
      void initState() {
        for (int i = 30; i >= 0; i--) {
          listItem.add('Message -------> $i');
        }
        super.initState();
      }
    
      void _incrementCounter() {
        final startIndex = listItem.length - 1;
        final endIndex = listItem.length + 30;
        for (int i = startIndex; i <= endIndex; i++) {
          listItem.insert(0, 'Message -------> $i');
        }
    
        setState(() {});
        scrollTop();
      }
    
      void scrollTop() {
        controller.animateTo(
          0,
          duration: const Duration(milliseconds: 500),
          curve: Curves.easeOut,
        );
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: ListView.builder(
            itemCount: listItem.length,
            shrinkWrap: true,
            controller: controller,
            itemBuilder: (context, index) => Container(
              margin: const EdgeInsets.all(8),
              color: Colors.deepPurple,
              height: 50,
              width: 100,
              child: Center(
                child: Text(
                  listItem[index],
                  style: const TextStyle(
                    color: Colors.white,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: _incrementCounter,
            child: const Icon(Icons.add),
          ),
        );
      }
    }
    
    Login or Signup to reply.
  2. Actually, the problem is that the viewport would layout its slivers using the new maxScrollExtent (that increased due to the newly added items). However, the ScrollPosition.pixels is still unchanged, while existing slivers have received their new scroll offset in their SliverGeometry.

    Consequently, slivers would paint their items using the new scroll offset and the old ScrollPosition.pixels (that would be updated once the painting is completed for the current frame).

    Therefore, we have three ways to align the new scroll offset and the old pixels.

    1. Comparing the diff between the old and new max scroll extent, and jumpTo(diff) using addPostFrameCallback, like below:
        final double old = _controller.position.pixels;
        final double oldMax = _controller.position.maxScrollExtent;
    
        WidgetsBinding.instance.addPostFrameCallback((_) {
          if (old > 0.0) {
            final diff = _controller.position.maxScrollExtent - oldMax;
            _controller.jumpTo(old + diff);
          }
        });
    

    This way would meet your requirements but the painting may flicker between the two frames since you actually do JumpTo normally. See the video link.

    1. Align the pixel difference during the layout phase. This way would extend the ScrollController and create a custom ScrollPosition to align the pixel difference when the viewport invokes ViewportOffset.applyContentDimensions during performLayout(). Eventually, you could invoke RetainableScrollController.retainOffset() to keep the scroll position when inserting new items at the top of the list view.
    class RetainableScrollController extends ScrollController {
      RetainableScrollController({
        super.initialScrollOffset,
        super.keepScrollOffset,
        super.debugLabel,
      });
    
      @override
      ScrollPosition createScrollPosition(
        ScrollPhysics physics,
        ScrollContext context,
        ScrollPosition? oldPosition,
      ) {
        return RetainableScrollPosition(
          physics: physics,
          context: context,
          initialPixels: initialScrollOffset,
          keepScrollOffset: keepScrollOffset,
          oldPosition: oldPosition,
          debugLabel: debugLabel,
        );
      }
    
      void retainOffset() {
        position.retainOffset();
      }
    
      @override
      RetainableScrollPosition get position =>
          super.position as RetainableScrollPosition;
    }
    
    class RetainableScrollPosition extends ScrollPositionWithSingleContext {
      RetainableScrollPosition({
        required super.physics,
        required super.context,
        super.initialPixels = 0.0,
        super.keepScrollOffset,
        super.oldPosition,
        super.debugLabel,
      });
    
      double? _oldPixels;
      double? _oldMaxScrollExtent;
    
      bool get shouldRestoreRetainedOffset =>
          _oldMaxScrollExtent != null && _oldPixels != null;
    
      void retainOffset() {
        if (!hasPixels) return;
        _oldPixels = pixels;
        _oldMaxScrollExtent = maxScrollExtent;
      }
    
      /// when the viewport layouts its children, it would invoke [applyContentDimensions] to
      /// update the [minScrollExtent] and [maxScrollExtent].
      /// When it happens, [shouldRestoreRetainedOffset] would determine if correcting the current [pixels],
      /// so that the final scroll offset is matched to the previous items' scroll offsets.
      /// Therefore, avoiding scrolling down/up when the new item is inserted into the first index of the list.
      @override
      bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
        final applied =
            super.applyContentDimensions(minScrollExtent, maxScrollExtent);
    
        bool isPixelsCorrected = false;
    
        if (shouldRestoreRetainedOffset) {
          final diff = maxScrollExtent - _oldMaxScrollExtent!;
          if (_oldPixels! > minScrollExtent && diff > 0) {
            correctPixels(pixels + diff);
            isPixelsCorrected = true;
          }
          _oldMaxScrollExtent = null;
          _oldPixels = null;
        }
    
        return applied && !isPixelsCorrected;
      }
    }
    

    The demo video could be found [here]

    1. The best way to achieve your goal is to use a special ScrollPhysics. Though this way, you do not need to change your existing codes, and just pass physics: const PositionRetainedScrollPhysics() in your list view.
    class PositionRetainedScrollPhysics extends ScrollPhysics {
      final bool shouldRetain;
      const PositionRetainedScrollPhysics({super.parent, this.shouldRetain = true});
    
      @override
      PositionRetainedScrollPhysics applyTo(ScrollPhysics? ancestor) {
        return PositionRetainedScrollPhysics(
          parent: buildParent(ancestor),
          shouldRetain: shouldRetain,
        );
      }
    
      @override
      double adjustPositionForNewDimensions({
        required ScrollMetrics oldPosition,
        required ScrollMetrics newPosition,
        required bool isScrolling,
        required double velocity,
      }) {
        final position = super.adjustPositionForNewDimensions(
          oldPosition: oldPosition,
          newPosition: newPosition,
          isScrolling: isScrolling,
          velocity: velocity,
        );
    
        final diff = newPosition.maxScrollExtent - oldPosition.maxScrollExtent;
    
        if (oldPosition.pixels > oldPosition.minScrollExtent &&
            diff > 0 &&
            shouldRetain) {
          return position + diff;
        } else {
          return position;
        }
      }
    }
    

    you could also use positioned_scroll_observer to use the PositionRetainedScrollPhysics and also other functions, like scrolling to a specific index in any scroll view.

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