23

Edit: I've edited the code below to feature the method that fetches the data along with the widgets that build the train estimates (replacing any API information along the way with "API_URL" and "API_STOP_ID"). I hope this even better helps us figure out the problem! I really appreciate any information anyone can give -- I've been working very hard on this project! Thank you all again!

Original post: I have a ListView of ListTiles that each have a trailing widget which builds train arrival estimates in a new Text widget. These trailing widgets are updated every five seconds (proven by print statements). As a filler for when the app is fetching data from the train's API, it displays a "no data" Text widget which is built by _buildEstimatesNull().

However, the problem is that "no data" is still being shown even when the app has finished fetching data and _isLoading = false (proven by print statements). Still, even if that was solved, the train estimates would become quickly outdated, as the trailing widgets are updating every five seconds on their own but this would not be reflected in the actual app as the widgets were built on page load. Thus, I need a way to rebuild those trailing widgets whenever they fetch new information.

Is there a way to have Flutter automatically rebuild the ListTile's trailing widget every five seconds as well (or whenever _buildEstimatesS1 is updated / the internals of the trailing widget is updated)?

class ShuttleApp extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return new ShuttleState();
  }
}

class ShuttleState extends State<ShuttleApp> {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      home: new HomeScreen(),
    );
  }
}

class HomeScreen extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return new HomeState();
  }
}

class HomeState extends State<HomeScreen> {

  var _isLoading = true;

  void initState() {
    super.initState();
    _fetchData();
    const fiveSec = const Duration(seconds: 5);
    new Timer.periodic(fiveSec, (Timer t) {
      _fetchData();
    });
  }

  var arrivalsList = new List<ArrivalEstimates>();

  _fetchData() async {
    arrivalsList.clear();
    stopsList.clear();
    final url = "API_URL";
    print("Fetching: " + url);
    final response = await http.get(url);
    final busesJson = json.decode(response.body);
    if (busesJson["service_id"] == null) {
      globals.serviceActive = false;
    } else {
      busesJson["ResultSet"]["Result"].forEach((busJson) {
        if (busJson["arrival_estimates"] != null) {
          busJson["arrival_estimates"].forEach((arrivalJson) {
            globals.serviceActive = true;
            final arrivalEstimate = new ArrivalEstimates(
                arrivalJson["route_id"], arrivalJson["arrival_at"], arrivalJson["stop_id"]
            );
            arrivalsList.add(arrivalEstimate);
          });
        }
      });
    }
    setState(() {
      _isLoading = false;
    });
  }

  Widget _buildEstimateNull() {
    return new Container(
      child: new Center(
        child: new Text("..."),
      ),
    );
  }

  Widget _buildEstimateS1() {
    if (globals.serviceActive == false) {
      print('serviceNotActive');
      _buildEstimateNull();
    } else {
      final String translocStopId = "API_STOP_ID";
      final estimateMatches = new List<String>();
      arrivalsList.forEach((arrival) {
        if (arrival.stopId == translocStopId) {
          estimateMatches.add(arrival.arrivalAt);
        }
      });
      estimateMatches.sort();
      if (estimateMatches.length == 0) {
        print("zero");
        return _buildEstimateNull();
      } else {
        return new Container(
          child: new Center(
            child: new Text(estimateMatches[0]),
          ),
        );
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        backgroundColor: const Color(0xFF171717),
        appBar: new AppBar(),
        body: new DefaultTextStyle(
          style: new TextStyle(color: const Color(0xFFaaaaaa),),
          child: new ListView(
            children: <Widget>[
              new ListTile(
                title: new Text('S1: Forest Hills',
                    style: new TextStyle(fontWeight: FontWeight.w500, fontSize: 20.0)),
                subtitle: new Text('Orange Line'),
                contentPadding: new EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0),
                trailing: _isLoading ? _buildEstimateNull() : _buildEstimateS1(),
              ),
            ],
          ),
        )
    );
  }

class ArrivalEstimates {
  final String routeId;
  final String arrivalAt;
  final String stopId;
  ArrivalEstimates(this.routeId, this.arrivalAt, this.stopId);
}

Thank you so much in advance for any help you can give! I really super appreciate it! :)

ctrinh
  • 375
  • 1
  • 4
  • 14
  • A bit too broad. It would help to have a bit of code, just enough to reproduce and fix the problem – Rémi Rousselet Jun 26 '18 at 01:13
  • @RémiRousselet I've updated the original post with the method that fetches the data along with the Widget that builds the train estimates in the main body of the app. Thank you for the suggestion! – ctrinh Jun 26 '18 at 03:38
  • It would be helpful to include a log from when you run your app - without that, it's hard to tell what actually happens. But 1: are you sure that busesJson["service_id"] != null?. And 2: The 'state' of the object is the arrivals list as well as _isLoading - that should be set in the setState as well. And instead of re-using the same list, you should probably be making a new one each time. 3: in _buildEstimateS1 if service not active you're not returning the result of _buildEstimateNull. – rmtmckenzie Jun 26 '18 at 07:31

4 Answers4

28

There are a few ways you could tackle this. It is slightly difficult however to tell what's going on without seeing a bit more of your code - specifically how you're getting the data and what you're doing with it. But I think I can give you a sufficient answer anyways.

The simple way of doing this is to either:

  1. Have a StatefulWidget which keeps track of the build estimates for all of the items in the list. It should request data from your API, get the results, and then call setState(() => this.listData = data);. The call to setState is what tells the widget that it needs to rebuild.
  2. Have a StatefulWidget for each item in the list. They would all each perform an API request every 5 seconds, get the results, and then each would call setState(() => this.itemData = data);. This means multiple calls to the API etc.

The advantage of #1 is that you can batch API calls, whereas the advantage to #2 is that your build would change less overall (although the way flutter works, this would be pretty minimal)... so I would probably go with #1 if possible.

However, there is a better way of doing this!

The better way of doing this is to have some sort of API Manager (or whatever you want to call it) which handles the communication with your API. It probably would live higher up in your widget tree and would be started/stopped with whatever logic you want. Depending on how far up the widget tree is, you could either pass it into each child or more likely hold it in an InheritedWidget which could then be used to retrieve it from each list element or from the overall list.

The API manager would provide various streams - either with a bunch of named fields/methods or with a getStream(id) sort of structure depending on your API.

Then, within your various list elements, you would use StreamBuilder widgets to build each of the elements based on the data - by using a StreamBuilder you get a ConnectionState object that lets you know whether the stream has received any data yet so you can choose to show an isLoading type widget instead of the one that shows data.

By using this more advanced method, you get:

  • Maintainability
    • If your API changes, you only have to change the API manager
    • You can write better testing as the API interactions and the UI interactions are separated
  • Extensibility
    • If you, later on, use push notifications for updates rather than pinging a server every 5 seconds, that can be incorporated into the API manager so that it can simply update the stream without touching the UI

EDIT: as per OP's comments, they have already implemented more or less the first suggestion. However, there are a few problems with the code. I'll list them below and I've posted the code with a couple of changes.

  1. The arrivalsList should be replaced each time a new build is done rather than simply being changed. This is because dart compares the lists and if it finds the same list, it doesn't necessarily compare all of the elements. Also, while changing it in the middle of a function isn't necessarily going to cause problems, it's generally better to use a local variable and then change the value at the end. Note that the member is actually set within setState.
  2. If serviceActive == false, the return was missed from return _buildEstimateNull();.

Here's the code:

class HomeState extends State<HomeScreen> {

  var _isLoading = true;

  void initState() {
    super.initState();
    _fetchData();
    const fiveSec = const Duration(seconds: 5);
    new Timer.periodic(fiveSec, (Timer t) {
      _fetchData();
    });
  }

  var arrivalsList = new List<ArrivalEstimates>();

  _fetchData() async {
    var arrivalsList = new List<ArrivalEstimates>(); // *********** #1
    stopsList.clear();
    final url = "API_URL";
    print("Fetching: " + url);
    final response = await http.get(url);
    final busesJson = json.decode(response.body);
    if (busesJson["service_id"] == null) {
      print("no service id");
      globals.serviceActive = false;
    } else {
      busesJson["ResultSet"]["Result"].forEach((busJson) {
        if (busJson["arrival_estimates"] != null) {
          busJson["arrival_estimates"].forEach((arrivalJson) {
            globals.serviceActive = true;
            final arrivalEstimate = new ArrivalEstimates(
                arrivalJson["route_id"], arrivalJson["arrival_at"], arrivalJson["stop_id"]
            );
            arrivalsList.add(arrivalEstimate);
          });
        }
      });
    }
    setState(() {
      _isLoading = false;
      this.arrivalsList = arrivalsList; // *********** #1
    });
  }

  Widget _buildEstimateNull() {
    return new Container(
      child: new Center(
        child: new Text("..."),
      ),
    );
  }

  Widget _buildEstimateS1() {
    if (globals.serviceActive == false) {
      print('serviceNotActive');
      return _buildEstimateNull();  // ************ #2
    } else {
      final String translocStopId = "API_STOP_ID";
      final estimateMatches = new List<String>();
      print("arrivalsList length: ${arrivalsList.length}");
      arrivalsList.forEach((arrival) {
        if (arrival.stopId == translocStopId) {
          print("Estimate match found: ${arrival.stopId}");
          estimateMatches.add(arrival.arrivalAt);
        }
      });
      estimateMatches.sort();
      if (estimateMatches.length == 0) {
        print("zero");
        return _buildEstimateNull();
      } else {
        return new Container(
          child: new Center(
            child: new Text(estimateMatches[0]),
          ),
        );
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        backgroundColor: const Color(0xFF171717),
        appBar: new AppBar(),
        body: new DefaultTextStyle(
          style: new TextStyle(color: const Color(0xFFaaaaaa),),
          child: new ListView(
            children: <Widget>[
              new ListTile(
                title: new Text('S1: Forest Hills',
                    style: new TextStyle(fontWeight: FontWeight.w500, fontSize: 20.0)),
                subtitle: new Text('Orange Line'),
                contentPadding: new EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0),
                trailing: _isLoading ? _buildEstimateNull() : _buildEstimateS1(),
              ),
            ],
          ),
        )
    );
  }
BananaNeil
  • 10,322
  • 7
  • 46
  • 66
rmtmckenzie
  • 37,718
  • 9
  • 112
  • 99
  • Your suggestion for the API manager is a wonderful idea! It sounds like this would work very well in a large-scale app and it's something I definitely would like to look into more! However, I want to inquire more about your first two suggestions on how to tackle this -- I think I've already done the first suggestion? My `_fetchData` method is in `class ShuttleState extends State` which follows `class ShuttleApp extends StatefulWidget`. I've updated the code in OP to reflect my app better -- could I ask you to please take another look and see if I went wrong somewhere? – ctrinh Jun 26 '18 at 03:45
4

Instead of clearing and re-using the arrivalsList, create a new list every time the data is fetched. Otherwise Flutter is unable to detect if the list has changed.

Also, the code would clearer if you called setState whenever you change the list.

_fetchData() async {

  final url = "API_URL";
  print("Fetching: " + url);
  final response = await http.get(url);
  final busesJson = json.decode(response.body);
  if (busesJson["service_id"] == null) {
    globals.serviceActive = false;
    setState(() {
      _isLoading = false;
    });
  } else {
    final newArrivalsList = new List<ArrivalEstimates>();
    busesJson["ResultSet"]["Result"].forEach((busJson) {
      if (busJson["arrival_estimates"] != null) {
        busJson["arrival_estimates"].forEach((arrivalJson) {
          globals.serviceActive = true;
          final arrivalEstimate = new ArrivalEstimates(
              arrivalJson["route_id"], arrivalJson["arrival_at"], arrivalJson["stop_id"]
          );
          newArrivalsList.add(arrivalEstimate);
        });
      }
    });
    setState(() {
      arrivalsList = newArrivalsList;
      _isLoading = false;
    });
  }
}

A few side notes:

I'm not sure if you actually want to clear the list before you fetch the data. If the state was updated properly, that would cause a flicker every 5 seconds.

I'm not sure if you simplified the code, but calling the _fetchData method every five seconds may become a problem if the network is slow.

boformer
  • 28,207
  • 10
  • 81
  • 66
  • I'd dissuade you from setting the state more than once, especially for the same variables. Also, it seems unnecessary to clear the list first at all. – rmtmckenzie Jun 26 '18 at 21:32
  • I updated the example code so that `setState` is only called once after the `await` statement. I have mentioned in the notes below the code that clearing the list is probably a bad idea, but it is a part of the original code snippet... – boformer Jun 26 '18 at 22:31
  • Fair enough. But there's a chance someone would simply copy that without reading your comments below - your answer isn't just for the OP but also anyone else who comes across it. – rmtmckenzie Jun 26 '18 at 22:34
  • 1
    I removed the part that clears the list. You are right, it could cause confusion and it's also very unlikely that it is what OP wants. – boformer Jun 26 '18 at 22:39
  • As mentioned above 5 sec would cause a flicker. 5 Sec is also to short/often for HTTP/TCP. It takes longer than 5 sec for {http.get(url);} to execute when the server can't be contacted. An exception will also be thrown so a try/catch block is nice to have. – Johan vdH Nov 14 '18 at 21:06
3

If you are certain that you want a child widget to rebuild every time you call setState() and it is stubbornly refusing, you can give it a UniqueKey(). This will ensure that when setState() triggers a rebuild the child widget keys will not match, the old widget will be popped and disposed of, and, the new widget will replace it in the widget tree.

Note that this is using keys in sort of the opposite way for which they were intended (to reduce rebuilding) but if something beyond your control is hindering necessary rebuilds then this is a simple, built-in way to achieve the desired goal.

Here is a very helpful Medium article on keys from one the Flutter team members, Emily Fortuna:

https://medium.com/flutter/keys-what-are-they-good-for-13cb51742e7d

jwehrle
  • 4,414
  • 1
  • 17
  • 13
2

I am not sure if this is what your looking for but and im probably late on this but i believe you can use a change notifier efficiently to achieve this. Basically a change notifier is hooked to your backed logic() for instance an api data fetch. A widget is then registered with a change notifier of the same type as the change notifier provider. In event of data change, the widgets registered with the change notifier will be rebuild.

For instance

// extend the change notifier class
class DataClass extends ChangeNotifier {
....
getData(){
   Response res = get('https://data/endpoint')
notifyListeners()
}


void onChange() {
  notifyListeners();
}
....
}

Every time there is change in data you call the notifyListeners() that will trigger rebuild of consuming widgets.

Register you widget with a changenotifier

class View extends StatefulWidget {
 Widget create(BuildContext context) {
  return ChangeNotifierProvider<ModelClass>(
    builder: (context) => DataClass(auth: auth),
    child: Consumer<ModelClass>(
      builder: (context, model, _) => View(model: model),
    ),
  );
}
}

You can also user a Consumer for the same. Get more on this from the Documentation

Bright
  • 707
  • 2
  • 10
  • 22