StreamBuilder Firestore Pagination

6,290

Solution 1

I'm gonna post my code i hope someone post a better solution, probably is not the best but it works.

In my app the actual solution is change the state of the list when reach the top, stop stream and show old messages.

All code (State)

class _MessageListState extends State<MessageList> {
  List<DocumentSnapshot> _messagesSnapshots;
  bool _isLoading = false;

  final TextEditingController _textController = TextEditingController();
  ScrollController listScrollController;
  Message lastMessage;
  Room room;

  @override
  void initState() {
    listScrollController = ScrollController();
    listScrollController.addListener(_scrollListener);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    room = widget.room;
    return Flexible(
      child: StreamBuilder<QuerySnapshot>(
        stream: _isLoading
            ? null
            : Firestore.instance
                .collection('rooms')
                .document(room.id)
                .collection('messages')
                .orderBy('timestamp', descending: true)
                .limit(20)
                .snapshots(),
        builder: (context, snapshot) {
          if (!snapshot.hasData) return LinearProgressIndicator();
          _messagesSnapshots = snapshot.data.documents;
          return _buildList(context, _messagesSnapshots);
        },
      ),
    );
  }

  Widget _buildList(BuildContext context, List<DocumentSnapshot> snapshot) {
    _messagesSnapshots = snapshot;

    if (snapshot.isNotEmpty) lastMessage = Message.fromSnapshot(snapshot[0]);

    return ListView.builder(
      padding: EdgeInsets.all(10),
      controller: listScrollController,
      itemCount: _messagesSnapshots.length,
      reverse: true,
      itemBuilder: (context, index) {
        return _buildListItem(context, _messagesSnapshots[index]);
      },
    );
  }

  Widget _buildListItem(BuildContext context, DocumentSnapshot data) {
    final message = Message.fromSnapshot(data);
    Widget chatMessage = message.sender != widget.me.id
        ? Bubble(
            message: message,
            isMe: false,
          )
        : Bubble(
            message: message,
            isMe: true,
          );
    return Column(
      children: <Widget>[chatMessage],
    );
  }

  loadToTrue() {
    _isLoading = true;
    Firestore.instance
        .collection('messages')
        .reference()
        .where('room_id', isEqualTo: widget.room.id)
        .orderBy('timestamp', descending: true)
        .limit(1)
        .snapshots()
        .listen((onData) {
      print("Something change");
      if (onData.documents[0] != null) {
        Message result = Message.fromSnapshot(onData.documents[0]);
        // Here i check if last array message is the last of the FireStore DB
        int equal = lastMessage?.compareTo(result) ?? 1;
        if (equal != 0) {
          setState(() {
            _isLoading = false;
          });
        }
      }
    });
  }

  _scrollListener() {
    // if _scroll reach top 
    if (listScrollController.offset >=
            listScrollController.position.maxScrollExtent &&
        !listScrollController.position.outOfRange) {
      final message = Message.fromSnapshot(
          _messagesSnapshots[_messagesSnapshots.length - 1]);
      // Query old messages
      Firestore.instance
          .collection('rooms')
          .document(widget.room.id)
          .collection('messages')
          .where('timestamp', isLessThan: message.timestamp)
          .orderBy('timestamp', descending: true)
          .limit(20)
          .getDocuments()
          .then((snapshot) {
        setState(() {
          loadToTrue();
          // And add to the list
          _messagesSnapshots.addAll(snapshot.documents);
        });
      });
      // For debug purposes
//      key.currentState.showSnackBar(new SnackBar(
//        content: new Text("Top reached"),
//      ));
    }
  }
}

The most important methods are:

_scrollListener

When reach the top i query old messages and in setState i set isLoading var to true and set with the old messages the array i m gonna show.

  _scrollListener() {
    // if _scroll reach top
    if (listScrollController.offset >=
            listScrollController.position.maxScrollExtent &&
        !listScrollController.position.outOfRange) {
      final message = Message.fromSnapshot(
          _messagesSnapshots[_messagesSnapshots.length - 1]);
      // Query old messages
      Firestore.instance
          .collection('rooms')
          .document(widget.room.id)
          .collection('messages')
          .where('timestamp', isLessThan: message.timestamp)
          .orderBy('timestamp', descending: true)
          .limit(20)
          .getDocuments()
          .then((snapshot) {
        setState(() {
          loadToTrue();
          // And add to the list
          _messagesSnapshots.addAll(snapshot.documents);
        });
      });
      // For debug purposes
//      key.currentState.showSnackBar(new SnackBar(
//        content: new Text("Top reached"),
//      ));
    }
  }

And loadToTrue that listen while we are looking for old messages. If there is a new message we re activate the stream.

loadToTrue

  loadToTrue() {
    _isLoading = true;
    Firestore.instance
        .collection('rooms')
        .document(widget.room.id)
        .collection('messages')
        .orderBy('timestamp', descending: true)
        .limit(1)
        .snapshots()
        .listen((onData) {
      print("Something change");
      if (onData.documents[0] != null) {
        Message result = Message.fromSnapshot(onData.documents[0]);
        // Here i check if last array message is the last of the FireStore DB
        int equal = lastMessage?.compareTo(result) ?? 1;
        if (equal != 0) {
          setState(() {
            _isLoading = false;
          });
        }
      }
    });
  }

I hope this helps anyone who have the same problem (@Purus) and wait until someone give us a better solution!

Solution 2

I have a way to archive it. Sorry for my bad english

bool loadMoreMessage = false; int lastMessageIndex = 25 /// assumed each time scroll to the top of ListView load more 25 documents When I scroll to the top of the ListView =>setState loadMoreMessage = true;

This is my code:

StreamBuilder<List<Message>>(
        stream:
            _loadMoreMessage ? _streamMessage(lastMessageIndex): _streamMessage(25),
        builder: (context, AsyncSnapshot<List<Message>> snapshot) {
          if (!snapshot.hasData) {
            return Container();
          } else {
            listMessage = snapshot.data;
            return NotificationListener(
              onNotification: (notification) {
                if (notification is ScrollEndNotification) {
                  if (notification.metrics.pixels > 0) {
                    setState(() {
                      /// Logic here!
                      lastMessageIndex = lastMessageIndex + 25;
                      _loadMoreMessage = true;
                    });
                  }
                }
              },
              child: ListView.builder(
                controller: _scrollController,
                reverse: true,
                itemCount: snapshot.data.length,
                itemBuilder: (context, index) {
                  return ChatContent(listMessage[index]);
                },
              ),
            );
          }
        },
      ),

Solution 3

First of all, I doubt such an API is the right backend for a chat app with live data - paginated APIs are better suited for static content. For example, what exactly does "page 2" refer to if 30 messages were added after "page 1" loaded? Also, note that Firebase charges for Firestore requests on a per-document basis, so every message which is requested twice hurts your quota and your wallet.

As you see, a paginated API with a fixed page length is probably not the right fit. That why I strongly advise you to rather request messages that were sent in a certain time interval. The Firestore request could contain some code like this:

.where("time", ">", lastCheck).where("time", "<=", DateTime.now())

Either way, here's my answer to a similar question about paginated APIs in Flutter, which contains code for an actual implementation that loads new content as a ListView scrolls.

Share:
6,290
Erik Mompean
Author by

Erik Mompean

Updated on December 08, 2022

Comments

  • Erik Mompean
    Erik Mompean about 1 year

    I m new to flutter, and I'm trying to paginate a chat when scroll reach top with streambuilder. The problem is: when i make the query in scrollListener streambuilder priorize his query above the scrollListener and returns de old response. Is there any way to do this? What are my options here? Thanks!

    Class ChatScreenState

    In initState I create the scroll listener.

      @override
    void initState() {
     listScrollController = ScrollController();
     listScrollController.addListener(_scrollListener);
     super.initState();
    }
    

    Here i create the StreamBuilder with the query limited to 20 last messages. Using the _messagesSnapshots as global List.

    @override
    Widget build(BuildContext context) {
     return Scaffold(
        key: key,
        appBar: AppBar(title: Text("Chat")),
        body: Container(
          child: Column(
            children: <Widget>[
              Flexible(
                  child: StreamBuilder<QuerySnapshot>(
                stream: Firestore.instance
                    .collection('messages')
                    .where('room_id', isEqualTo: _roomID)
                    .orderBy('timestamp', descending: true)
                    .limit(20)
                    .snapshots(),
                builder: (context, snapshot) {
                  if (!snapshot.hasData) return LinearProgressIndicator();
                  _messagesSnapshots = snapshot.data.documents;
    
                  return _buildList(context, _messagesSnapshots);
                },
              )),
              Divider(height: 1.0),
              Container(
                decoration: BoxDecoration(color: Theme.of(context).cardColor),
                child: _buildTextComposer(),
              ),
            ],
          ),
        ));
    }
    
    Widget _buildList(BuildContext context, List<DocumentSnapshot> snapshot) {
     _messagesSnapshots = snapshot;
    
     return ListView.builder(
       controller: listScrollController,
       itemCount: _messagesSnapshots.length,
       reverse: true,
       itemBuilder: (context, index) {
         return _buildListItem(context, _messagesSnapshots[index]);
       },
     );
    }
    

    And in the _scollListener method i query the next 20 messages and add the result to the Global list.

      _scrollListener() {
    
       // If reach top 
       if (listScrollController.offset >=
            listScrollController.position.maxScrollExtent &&
        !listScrollController.position.outOfRange) {
    
       // Then search last message
       final message = Message.fromSnapshot(
          _messagesSnapshots[_messagesSnapshots.length - 1]);
    
       // And get the next 20 messages from database
       Firestore.instance
          .collection('messages')
          .where('room_id', isEqualTo: _roomID)
          .where('timestamp', isLessThan: message.timestamp)
          .orderBy('timestamp', descending: true)
          .limit(20)
          .getDocuments()
          .then((snapshot) {
    
        // To save in the global list
        setState(() {
          _messagesSnapshots.addAll(snapshot.documents);
        });
      });
    
      // debug snackbar
      key.currentState.showSnackBar(new SnackBar(
        content: new Text("Top Reached"),
      ));
     }
    }
    
  • Itiel Maimon
    Itiel Maimon over 4 years
    Great answer, I would like to add a better practice. You should cancel any previous instances of the "loadToTrue" stream. You can do it like this: Declare a field e.g StreamSubscription<QuerySnapshot> onChangeSubscription; and then save the instance of the stream to that filed, and inside _scrollListener do // Cancel previous instance of subscription to the loadToTrue stream. if (onChangeSubscription != null) { onChangeSubscription.cancel(); } // Start a new instance of subscription to the loadToTrue stream. loadToTrue();
  • aldobaie
    aldobaie almost 4 years
    Actually, I believe Firestore doesn't charge for duplicate document requests. They charge 1 document read per query + each new document fetched + each document update (content has changed). They have session limit + a time limit where they recharge for same document request.