2
votes

I am building a Flutter app and have run into a problem with the ListView.builder.

My current setup is a StreamBuilder which has a list of objects. Each object is bound to 1 item in the ListView.builder.

My problem is that when new items are added and I called StreamController$add() the list is automatically scrolled to the top of the list. I am looking for the list position to be maintained and it is up to the user (or a button) to scroll to the top.

I have added keys to the list items which helped other issues but not this one. I also added a PageStorageKey to the listview but that did not help. I have a scroll controller also but have not been able to figure out how to work this behavior.

I can provide code samples if needed. Thanks in advance!

Relevant code:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: albatrossDarkTheme,
      home: TimelinePage(),
    );
  }
}

class TimelinePage extends StatefulWidget {
  TimelinePage({Key key}) : super(key: key);

  @override
  _TimelinePageState createState() => _TimelinePageState();
}

class _TimelinePageState extends State<TimelinePage> {
  AlbatrossClient _client;
  GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
  PageStorageKey _pageKey = PageStorageKey(8);
  TimelineDatabase _database = TimelineDatabase();
  StreamController<List<Tweet>> _controller;
  ScrollController _scrollController;

  Stream<List<Tweet>> _getStream() {
    return _controller.stream;
  }

  Future<void> _refreshTimeline() async {
    _client.refreshTimeline().then((result) {
      if (result == 0)
        _database.getTimeline(false).then((update) {
          _controller.add(update);
        });
    });
  }

  _updateTimeline() async {
    _database.getTimeline(false).then((update) => _controller.add(update));
  }

  @override
  void initState() {
    _client = AlbatrossClient();
    _controller = StreamController<List<Tweet>>();
    _scrollController = ScrollController(keepScrollOffset: true);
    _database.getTimeline(false).then((saved) => _controller.add(saved));
      _client.refreshTimeline().then((result) {
        if (result == 0)
          _database.getTimeline(false).then((update) {
            _controller.add(update);
          });
      });

    super.initState();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      key: _scaffoldKey,
      appBar: SearchAppbar(() {
        _scaffoldKey.currentState.openDrawer();
      }),
      drawer: Drawer(
        child: DrawerMain.getDrawer(context, _client.getProfile()),
      ),
      body: Container(
          child: RefreshIndicator(
              onRefresh: _refreshTimeline,
              child: StreamBuilder<List<Tweet>>(
                stream: _getStream(),
                builder: (context, snapshot) {
                  if (snapshot.hasData) {
                    return ListView.builder(
                      key: _pageKey,
                      controller: _scrollController,
                      itemCount:
                          snapshot.data != null ? snapshot.data.length : 0,
                      itemBuilder: (context, index) {
                        return RowTweet(
                            tweet: snapshot.data[index],
                            update: _updateTimeline,
                            animate: true);
                      },
                    );
                  } else
                    return Container(
                      alignment: Alignment(0.0, 0.0),
                      child: CircularProgressIndicator(),
                    );
                },
              ))),
      floatingActionButton: FloatingActionButton(
        tooltip: 'Compose Tweet',
        onPressed: _composeTweet,
        foregroundColor: Colors.white,
        child: Image.asset(
          "icons/ic_tweet_web.webp",
          width: 38,
          height: 38,
        ),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

Here is my new attempt:

return Scaffold(
      key: _scaffoldKey,
      appBar: SearchAppbar(() {
        _scaffoldKey.currentState.openDrawer();
      }),
      drawer: Drawer(
        child: DrawerMain.getDrawer(context, _client.getProfile()),
      ),
      body: Container(
          child: RefreshIndicator(
              onRefresh: _refreshTimeline,
              child: _timeline.isNotEmpty
                  ? ListView.builder(
                      key: _pageKey,
                      controller: _scrollController,
                      itemCount: _timeline.length,
                      itemBuilder: (context, index) {
                        return RowTweet(
                            tweet: _timeline[index],
                            update: _updateTimeline,
                            animate: true);
                      })
                  : Container(
                      alignment: Alignment(0, 0),
                      child: CircularProgressIndicator(),
                    ))),
      floatingActionButton: FloatingActionButton(
        tooltip: 'Compose Tweet',
        onPressed: _composeTweet,
        foregroundColor: Colors.white,
        child: Image.asset(
          "icons/ic_tweet_web.webp",
          width: 38,
          height: 38,
        ),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
1

1 Answers

1
votes

So what's happening is that the entire list view is being rebuilt every time a new value is emitted through your stream. The way you solve this is to move the list view out of the stream builder and only modify the children of the list view in the stream builder.

I haven't used StreamBuilder extensively before so you'd have to figure it out in code. There's 2 ways this can be fixed.

  1. Keep a list of widgets in a member variable, return your indicator if list it empty, otherwise return a ListView(_yourMemberListOfWidgets). Hook up to the stream without the stream builder. When a new value is returned set that to the list of widgets kept as a member variable and call setState to update your state.

  2. Move your Stream builder into the ListView, compile and return the children only. If that's not possible then consider placing the StreamBuilder in a ScrollView and return a Column with all the children in there as widgets. This will give the same effect. If you need touch input, just use GestureDetector around your list items and implement the onTap.