3

I am trying to update my list of Thread objects when I reach near the end of the screen while scrolling down (to show an infinite list of items while I keep scrolling down)

My current setup is the following:

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';

import 'dart:async';
import 'dart:convert';

import 'forums.dart';

// Retrieve JSON response forum thread list
Future<List<Thread>> fetchForumThreadList(String url, int page) async {
    final response =
        await http.get('http://10.0.2.2:8080/frest$url/page/$page');
    if (response == null) {
        throw new Exception("No site");
    }
    if (response.statusCode == 200) {
        return compute(parseForumThreadList, response.body);
    } else {
        List<Thread> e = [];
        return e;
    }
}

List<Thread> parseForumThreadList(String responseBody) {
    Map decoded = json.decode(responseBody);
    List<Thread> threads = [];
    Map threadList = decoded["list"];
    for (var thread in threadList["List"]) {
        threads.add(Thread(
            thread["ID"],
            thread["Staff"],    
            thread["Support"],
            thread["Sticky"],
            thread["Locked"],
            thread["Title"],
            thread["Replies"],
            thread["Views"],
            thread["Author"],
            thread["CreatedAt"],
        ));
    }
    return threads;
}

// Generate a card list from a List of forum threads
Widget generateForumThreadList(BuildContext context, int index, List<Thread> data) {
    // Use custom icon for staff posts
    IconData authorIcon = Icons.account_circle;
    Color authorIconColor = Color(0xAAFFFFFF);
    if (data[index].staff) {
        authorIcon = Icons.verified_user;
        authorIconColor = Color(0xAAFFBA08);
    }
    return Column(
        children: <Widget>[
            InkWell(
                onTap: () { 

                },
                child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: <Widget>[
                        // We need to wrap columns under Flexible
                        // To make text wrap if larger than screen width
                        Flexible(
                            child: Column(
                                crossAxisAlignment: CrossAxisAlignment.start,
                                children: <Widget>[
                                    Padding(
                                        padding: const EdgeInsets.fromLTRB(12.0, 12.0, 12.0, 6.0),
                                        child: Text(
                                            data[index].title,
                                            style: TextStyle(
                                                fontSize: 22.0,
                                                fontWeight: FontWeight.bold,
                                            ),
                                        ),
                                    ),
                                    Padding(
                                        padding: const EdgeInsets.fromLTRB(12.0, 1.0, 12.0, 2.0),
                                        // Add thread author and created date
                                        child: Row(
                                            children: <Widget>[
                                                Icon(
                                                    authorIcon,
                                                    size: 15.0,
                                                    color: authorIconColor,
                                                ),
                                                Padding(
                                                    padding: const EdgeInsets.fromLTRB(5.0, 12.0, 12.0, 12.0),
                                                    child: Text(
                                                        "Author: " + data[index].author,
                                                    ),
                                                ),
                                            ],
                                        ),
                                    ),
                                    Padding(
                                        padding: const EdgeInsets.fromLTRB(12.0, 1.0, 12.0, 12.0),
                                        // Add threads and posts information
                                        child: Row(
                                            children: <Widget>[
                                                Icon(
                                                    Icons.chat_bubble,
                                                    size: 15.0,
                                                    color: Color(0xAAFFFFFF),
                                                ),
                                                Padding(
                                                    padding: const EdgeInsets.fromLTRB(5.0, 12.0, 12.0, 12.0),
                                                    child: Text(
                                                        data[index].replies.toString() + " Replies",
                                                    ),
                                                ),
                                                Icon(
                                                    Icons.pageview,
                                                    size: 15.0,
                                                    color: Color(0xAAFFFFFF),
                                                ),
                                                Padding(
                                                    padding: const EdgeInsets.fromLTRB(5.0, 12.0, 12.0, 12.0),
                                                    child: Text(
                                                        data[index].views.toString() + " Views",
                                                    ),
                                                ),
                                            ],  
                                        ),
                                    ),
                                ],
                            ),
                        ),
                    ],
                ),
            ),
            // Add a divider for each forum item
            Divider(
                height: 4.0,
            ),
        ],
    );  
}

// Class used for a forum thread
class Thread {
    final int id;
    final bool staff;
    final bool support;
    final bool sticky;
    final bool locked;
    final String title;
    final int replies;
    final int views;
    final String author;
    final String createdAt; 

    Thread(
        this.id,
        this.staff,
        this.support,
        this.sticky,
        this.locked,
        this.title,
        this.replies,
        this.views,
        this.author,
        this.createdAt,
    );
}

class ForumThreadList extends StatefulWidget {
    final Forum forum;
    ForumThreadList(this.forum);

    @override
    _ForumThreadListState createState() => _ForumThreadListState(forum);
}

class _ForumThreadListState extends State<ForumThreadList> {
    final Forum forum;
    int page = 1;
    ScrollController controller;    

    _ForumThreadListState(this.forum);

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

    @override
    void dispose() {
        controller.removeListener(_scrollListener);
        super.dispose();
    }

    VoidCallback _scrollListener() {
        // If we are near the end of the list
        if (controller.position.extentAfter < 300) {
            page++;
            print(page);
        }
    }

    @override
    Widget build(BuildContext context) {
        return Scaffold(
            appBar: AppBar(
                title: Text(forum.name),
            ),
            body: Scrollbar(
                child: Center(
                    child: FutureBuilder<List<Thread>>(
                        future: fetchForumThreadList(forum.url, page),
                        builder: (context, snapshot) {
                            if (snapshot.hasData) {
                                return new ListView.builder(
                                    controller: controller,
                                    itemCount: snapshot.data.length,
                                    itemBuilder: (BuildContext ctx, int index) {
                                        return generateForumThreadList(ctx, index, snapshot.data);
                                    },
                                );
                            } else if (snapshot.hasError) {
                                return Text(snapshot.error);
                            }   
                            return CircularProgressIndicator();
                        },
                    ),
                ),
            ),
            // Add floating button to reload forums
            floatingActionButton: new FloatingActionButton(
                elevation: 0.0,
                child: Icon(
                    Icons.sync,
                    size: 32.0,
                ),
                // When pressing the button reload the forum list
                onPressed: () {  },
            ),
        );
    }
}

Right now everything works fine, when I load the application the first page of my API is fetched and the list is populated, however I cant figure how to append the next page of elements when I am near the end of the screen.

I tried updating my page variable when near the end thinking that this would make the FutureBuilder update, but this does not seem to be correct and I also think this wont give me my desired result (making the list expand instead of replacing items with a new batch).

Raggaer
  • 3,244
  • 8
  • 36
  • 67
  • 1
    see https://codeshare.io/504AXv – pskink Jan 13 '19 at 17:04
  • @pskink seems to work fine, thanks for the help. I also saw that I should be using a `StreamBuilder` instead of a `FutureBuilder` if I ever want to update – Raggaer Jan 13 '19 at 18:05
  • 1
    It also makes sense to separate the loading logic from the UI. Have a look at my answer here: https://stackoverflow.com/questions/53729985/flutter-display-content-from-paginated-api-with-dynamic-listview/53841830#53841830 – Marcel Jan 13 '19 at 20:01

1 Answers1

2

What you're looking for seems to be List pagination. Checking your repro, it's unable to trigger a callback when the scroll is at the end of the List. Instead of _scrollListener(), you can add this listener for the ScrollController.

_scrollController.addListener(() {
  if (_scrollController.position.atEdge) {
    if (_scrollController.position.pixels == 0)
      debugPrint('List scroll at top');
    else {
      debugPrint('List scroll at bottom');
      // Scroll is at the end of the page, load next page
      loadMoreImages(true);
    }
  }
});

Once the bottom of the page is hit, call a method that should add more items on the ListView.

// Succeeding pages will display 3 more items from the List
loadMoreImages(bool increment) {
  setState(() {
  if (!increment)
    // if increment is set to false
    // List will only show first page
    _listCursorEnd = 3;
  else
    // else, add items to load next page
    _listCursorEnd += 3;
  });
}

On the subject of using StreamBuilder, one way to utilize this feature is to set the List items in the StreamController.

var _streamController = StreamController<List<Album>>();

To add content on the StreamController, use StreamController.add()

fetchAlbum().then((response) => _streamController.add(response));

Then add the StreamBuilder on Widget build() and use the snapshot data from StreamBuilder to populate the ListView.

StreamBuilder(
  stream: _streamController.stream,
  builder: (BuildContext context, AsyncSnapshot<List<Album>> snapshot) {
    if (snapshot.hasData) {
      // This ensures that the cursor won't exceed List<Album> length
      if (_listCursorEnd > snapshot.data.length)
        _listCursorEnd = snapshot.data.length;
    }
    return Widget(); // Populate ListView widget using snapshot data
  },
);
   

Here's the complete code. The sample also demonstrates a "pull to refresh" function as a bonus.

import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

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

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

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

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  var _streamController = StreamController<List<Album>>();
  var _scrollController = ScrollController();

  // Succeeding pages should display 3 more items from the List
  loadMoreImages(bool increment) {
    setState(() {
      if (!increment)
        _listCursorEnd = 3;
      else
        _listCursorEnd += 3;
    });
  }

  // Call to fetch images
  loadImages(bool refresh) {
    fetchAlbum().then((response) => _streamController.add(response));
    if (refresh) loadMoreImages(!refresh); // refresh whole List
  }

  @override
  void initState() {
    super.initState();
    loadImages(false);
    _scrollController.addListener(() {
      if (_scrollController.position.atEdge) {
        if (_scrollController.position.pixels == 0)
          print('List scroll at top');
        else {
          print('List scroll at bottom');
          loadMoreImages(true);
        }
      }
    });
  }

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

  var _listCursorEnd = 21;

  @override
  Widget build(BuildContext context) {
    return StreamBuilder(
      stream: _streamController.stream,
      builder: (BuildContext context, AsyncSnapshot<List<Album>> snapshot) {
        if (snapshot.hasData) {
          // This ensures that the cursor won't exceed List<Album> length
          if (_listCursorEnd > snapshot.data.length)
            _listCursorEnd = snapshot.data.length;
          debugPrint('Stream snapshot contains ${snapshot.data.length} item/s');
        }
        return Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: Center(
            child: RefreshIndicator(
              // onRefresh is a RefreshCallback
              // RefreshCallback is a Future Function().
              onRefresh: () async => loadImages(true),
              child: snapshot.hasData
                  ? ListView.builder(
                      controller: _scrollController,
                      primary: false,
                      padding: const EdgeInsets.all(20),
                      itemBuilder: (context, index) {
                        if (index < _listCursorEnd) {
                          return Container(
                            padding: const EdgeInsets.all(8),
                            child: Image.network(
                                snapshot.data[index].albumThumbUrl,
                                fit: BoxFit.cover),
                            // child: Thumbnail(image: imagePath, size: Size(100, 100)),
                          );
                        } else
                          return null;
                      },
                    )
                  : Text('Waiting...'),
            ),
          ),
        );
      },
    );
  }

  Future<List<Album>> fetchAlbum() async {
    final response =
        await http.get('https://jsonplaceholder.typicode.com/photos');

    if (response.statusCode == 200) {
      // If the server did return a 200 OK response,
      // then parse the JSON.
      Iterable iterableAlbum = json.decode(response.body);
      var albumList = List<Album>();
      List<Map<String, dynamic>>.from(iterableAlbum).map((Map model) {
        // Add Album mapped from json to List<Album>
        albumList.add(Album.fromJson(model));
      }).toList();
      return albumList;
    } else {
      // If the server did not return a 200 OK response,
      // then throw an exception.
      throw Exception('Failed to load album');
    }
  }

  getListImg(List<Album> listAlbum) {
    var listImages = List<Widget>();
    for (var album in listAlbum) {
      listImages.add(
        Container(
          padding: const EdgeInsets.all(8),
          child: Image.network(album.albumThumbUrl, fit: BoxFit.cover),
          // child: Thumbnail(image: imagePath, size: Size(100, 100)),
        ),
      );
    }
    return listImages;
  }
}

class Album {
  final int albumId;
  final int id;
  final String title;
  final String albumImageUrl;
  final String albumThumbUrl;

  Album(
      {this.albumId,
      this.id,
      this.title,
      this.albumImageUrl,
      this.albumThumbUrl});

  factory Album.fromJson(Map<String, dynamic> json) {
    return Album(
      albumId: json['albumId'],
      id: json['id'],
      title: json['title'],
      albumImageUrl: json['url'],
      albumThumbUrl: json['thumbnailUrl'],
    );
  }
}

demo

Omatt
  • 8,564
  • 2
  • 42
  • 144