25

I'm looking for a way to insert new items into a list view, while maintaining the scroll offset of the user. Basically like a twitter feed after pulling to refresh: the new items get added on top, while the scroll position is maintained. The user can then just scroll up to see the newly added items.

If I just rebuild the list/scroll widget with a couple of new items at the beginning, it -of course- jumps, because the height of the scroll view content increased. Just estimating the height of those new items to correct the jump is not an option, because the content of the new items is variable. Even the AnimatedList widget which provides methods to dynamically insert items at an arbitrary position jumps when inserting at index 0.

Any ideas on how to approach this? Perhaps calculating the height of the new items beforehand using an Offstage widget?

florisvdg
  • 251
  • 3
  • 3
  • Can you see if these links are helpful: [stackoverflow](https://stackoverflow.com/questions/45341721/flutter-listview-inside-on-a-tabbarview-loses-its-scroll-position) , [medium](https://medium.com/@boldijar.paul/flutter-keeping-list-view-index-while-changing-page-view-c260352f35f8) – Thanthu Sep 05 '18 at 09:03
  • @Thanthu: thanks, but those links are about remembering the scroll offset when rebuilding (when switching tabs for example), in my case it's about maintaining the user's perceived scroll offset when prepending new items in an existing list. – florisvdg Sep 05 '18 at 09:40
  • Please check the answer I just posted now. It's working for me. I can add more details if you require. – Thanthu Sep 05 '18 at 16:53
  • Were you able to find a solution to this? I seem to be having the same problem – Nick Mowen Feb 21 '19 at 17:14
  • 1
    @NickMowen: unfortunately not. I created an issue for it: https://github.com/flutter/flutter/issues/21541 – florisvdg Feb 24 '19 at 08:39
  • @florisvdg did you find a solution for this? – tudorprodan Mar 26 '20 at 09:26

3 Answers3

7

Ran into this problem recently: I have a chat scroll that async loads previous or next messages depending on the direction of the scroll. This solution worked out for me.
The idea of the solution is the following. You create two SliverLists and put them inside CustomScrollView.

CustomScrollView(
  center: centerKey,
  slivers: <Widget>[
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (BuildContext context, int index) {
            return Container(
              // Here we render elements from the upper group
              child: top[index]
            )
        }
    ),
    SliverList(
      // Key parameter makes this list grow bottom
      key: centerKey,
      delegate: SliverChildBuilderDelegate(
        (BuildContext context, int index) {
            return Container(
              // Here we render elements from the bottom group
              child: bottom[index]
            )
        }
    ),
)

The first list scrolls upwards while the second list scrolls downwards. Their offset zero points are fixed at the same point and never move. If you need to prepend an item you push it to the top list, otherwise, you push it to the bottom list. That way their offsets don't change and your scroll view does not jump.
You can find the solution prototype in the following dartpad example.

UPD: Fixed null safety issue in this dartpad example.

Arsenii Burov
  • 111
  • 2
  • 4
2

I don't know if you managed to solve it... Marcin Szalek has posted a very nice solution on his blog about implementing an infinite dynamic list. I tried it and works like a charm with a ListView. I then tried to do it with an AnimatedList, but experienced the same issue that you reported (jumping to the top after each refresh...). Anyway, a ListView is quite powerful and should do the trick for you! The code is:

import 'dart:async';

import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      theme: new ThemeData(primarySwatch: Colors.blue),
      home: new MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  List<int> items = List.generate(10, (i) => i);
  ScrollController _scrollController = new ScrollController();
  bool isPerformingRequest = false;

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(() {
      if (_scrollController.position.pixels ==
          _scrollController.position.maxScrollExtent) {
        _getMoreData();
      }
    });
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  _getMoreData() async {
    if (!isPerformingRequest) {
      setState(() => isPerformingRequest = true);
      List<int> newEntries = await fakeRequest(
          items.length, items.length + 10); //returns empty list
      if (newEntries.isEmpty) {
        double edge = 50.0;
        double offsetFromBottom = _scrollController.position.maxScrollExtent -
            _scrollController.position.pixels;
        if (offsetFromBottom < edge) {
          _scrollController.animateTo(
              _scrollController.offset - (edge - offsetFromBottom),
              duration: new Duration(milliseconds: 500),
              curve: Curves.easeOut);
        }
      }
      setState(() {
        items.addAll(newEntries);
        isPerformingRequest = false;
      });
    }
  }

  Widget _buildProgressIndicator() {
    return new Padding(
      padding: const EdgeInsets.all(8.0),
      child: new Center(
        child: new Opacity(
          opacity: isPerformingRequest ? 1.0 : 0.0,
          child: new CircularProgressIndicator(),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: AppBar(
        title: Text("Infinite ListView"),
      ),
      body: ListView.builder(
        itemCount: items.length + 1,
        itemBuilder: (context, index) {
          if (index == items.length) {
            return _buildProgressIndicator();
          } else {
            return ListTile(title: new Text("Number $index"));
          }
        },
        controller: _scrollController,
      ),
    );
  }
}

/// from - inclusive, to - exclusive
Future<List<int>> fakeRequest(int from, int to) async {
  return Future.delayed(Duration(seconds: 2), () {
    return List.generate(to - from, (i) => i + from);
  });
}

A gist containing whole class can be found here.

  • 2
    You should provide the relevant parts from the link into your answer so that if the blog post is ever deleted in the future, readers will still be able to make use of your answer. – chevybow May 13 '19 at 21:50
  • 6
    this solution adds items to the end of the list. OP was asking for a way to add items at the beginning of your list (so scrolling up, not down) – Sonius Aug 13 '19 at 12:39
  • 2
    this answer is excatly opposite of what @florisvdg asked in the question. – Ravinder Kumar Dec 09 '20 at 11:17
1

I think reverse + lazyLoading will help you.

Reverse a list:

ListView.builder(reverse: true, ...);

for lazyLoading refer here.

Dinesh Balasubramanian
  • 20,532
  • 7
  • 64
  • 57
  • 1
    This may be a solution for some use cases, but in my case my list already supports lazy loading new items at the bottom. So I need twitter feed behavior: lazily loading when scrolling at the bottom (which is fairly straightforward) _and_ pull to refresh at the top, but without kicking the user's scroll offset up (which seems to be harder). – florisvdg Sep 05 '18 at 09:49
  • Oh sorry. I misunderstood the question. – Dinesh Balasubramanian Sep 05 '18 at 10:00
  • @florisvdg did you find any solution? – Jaya Prakash Sep 30 '20 at 15:39