Flutter - How to update state (or value?) of a Future/List used to build ListView (via FutureBuilder)

user3249281 picture user3249281 · Jul 26, 2019 · Viewed 15.7k times · Source

Im pasting relevant code below, but you may be able to answer it based upon my pseudo-explanation.

Im using a FutureBuilder to build a listview.

  • I start off by using init() to async HTTP call the API and parse that into an List of objects (Location) mapped to represent the json result.
  • That list of Location(s) is then returned into the Future<List<Location>> _listFuture variable (which is the future for the FutureBuilder).
  • Once the future "returns" or "finishes" the FutureBuilder kicks off and uses ListView.builder/Container/ListTile to loop through and build the list.
  • At some point, Im going to want an onTap() handler (in the ListTile) that changes the background color of whatever list item is selected.
  • To support that, I have a backgroundColor member in the Location class (that holds the JSON responses) that I default to "#fc7303" for all items (assume everything is always initially unchecked). i then want to change the background of whatever is selected to "#34bdeb" in onTap().
  • I'm assuming that I could call setState() which would trigger a refresh and the new background color would be noticed/used on redraw.

The problem is that the ListView/Contrainer/ListTile is driven by a

Future<List<Location>>

. I can pass the "tapped" index to my ontap handler, but I dont believe I can have my _changeBackground() just update the backgroundColor value for the selected index and call setState() because you cannot directly access/update a future like that (I get the error ERROR: The operator '[]' isn't defined for the class 'Future<List<Location>>'.)

Im not sure Im taking the right approach. In this case, I guess I could always theoretically separate the "background" color tracking into a new separate List (outside of the future) and track/reference it that way using aligned indexes from onTap().

However, I am not sure that would always work. In the future, I might need to actually change the values/state of what was returned in the future. For instance, think of if I wanted to be able to click on a list item and update the "companyName". In this case, I would be changing a value stored in the future directly. I guess I could technically send the new name to the server and completely refresh the list that way, but that seems inefficient (what if they decide to "cancel" and not save changes?).

Any help is appreciated. thanks!

this class actually holds the relevant data for the list

// Location
class Location {

  // members
  String locationID;
  String locationName;
  String companyName;
  String backgroundColor = 'fc7303';

  // constructor?
  Location({this.locationID, this.locationName, this.companyName});

  // factory?
  factory Location.fromJson(Map<String, dynamic> json) {
    return Location(
      locationID: json['locationID'],
      locationName: json['locationName'],
      companyName: json['companyName'],
    );

  }

}

this class is the parent json response that has "result" (success/error) messages. it instantiates the class above as a list to track the actual company/location records

//jsonResponse
class jsonResponse{

  String result;
  String resultMsg;
  List<Location> locations;

  jsonResponse({this.result, this.resultMsg, this.locations});

  factory jsonResponse.fromJson(Map<String, dynamic> parsedJson){

    var list = parsedJson['resultSet'] as List;
    List<Location> locationList = list.map((i) => Location.fromJson(i)).toList();
    return jsonResponse(
        result: parsedJson['result'],
        resultMsg: parsedJson['resultMsg'],
        locations: locationList
    );
  }

} // jsonResponse

here is the state and stateful widgets that use the classes above to parse API data and create ListView

class locationsApiState extends State<locationsApiWidget> {

  // list to track AJAX results
  Future<List<Location>> _listFuture;

  // init - set initial values
  @override
  void initState() {
    super.initState();
    // initial load
    _listFuture = updateAndGetList();
  }

  Future<List<Location>> updateAndGetList() async {

    var response = await http.get("http://XXX.XXX.XXX.XXX/api/listCompanies.php");
    if (response.statusCode == 200) {
      var r1 = json.decode(response.body);
      jsonResponse r = new jsonResponse.fromJson(r1);
      return r.locations;
    } else {
      throw Exception('Failed to load internet');
    }

  }

  _changeBackground(int index){
    print("in changebackground(): ${index}");       // this works!
    _listFuture[index].backgroundColor = '34bdeb';   // ERROR: The operator '[]' isn't defined for the class 'Future<List<Location>>'.
  }

  // build() method
  @override
  Widget build(BuildContext context) {

    return new FutureBuilder<List<Location>>(
        future: _listFuture,
        builder: (context, snapshot){

          if (snapshot.connectionState == ConnectionState.waiting) {
            return new Center(
              child: new CircularProgressIndicator(),
            );
          } else if (snapshot.hasError) {
            return new Text('Error: ${snapshot.error}');
          } else {
            final items = snapshot.data;
            return new Scrollbar(
              child: new RefreshIndicator(
                  child: ListView.builder(
                    physics: const AlwaysScrollableScrollPhysics(),
                    //Even if zero elements to update scroll
                    itemCount: items.length,
                    itemBuilder: (context, index) {
                      return
                        Container(
                            color: HexColor(items[index].backgroundColor),
                            child:
                            ListTile(
                              title: Text(items[index].companyName),
                              onTap: () {
                                print("Item at $index is ${items[index].companyName}");
                                _changeBackground(index);
                              }  // onTap
                            )
                        );
                    },
                  ),
                  onRefresh: () {
                    // implement later
                    return;
                  } // refreshList,
              ),
            );
          }// else
        } // builder
    ); // FutureBuilder
  } // build
} // locationsApiState class


class locationsApiWidget extends StatefulWidget {
  @override
  locationsApiState createState() => locationsApiState();
}

helper class (taken from somewhere on stackoverflow) for converting HEX to integer colors

class HexColor extends Color {
  static int _getColorFromHex(String hexColor) {
    hexColor = hexColor.toUpperCase().replaceAll("#", "");
    if (hexColor.length == 6) {
      hexColor = "FF" + hexColor;
    }
    return int.parse(hexColor, radix: 16);
  }

  HexColor(final String hexColor) : super(_getColorFromHex(hexColor));
}

Thanks!

Answer

spenster picture spenster · Jul 29, 2019

What I would recommend is removing the background color from your location class, and instead storing in your State which locations are selected. That way, your location list doesn't need to change when items are selected. I would also create a StatelessWidget for your location item, which would set the background color, depending on whether it's selected or not. So:

// for the LocationItem widget callback
typedef void tapLocation(int index);

class locationsApiState extends State<locationsApiWidget> {

  // list to track AJAX results
  Future<List<Location>> _listFuture;
  final var selectedLocationIndices = Set<int>();

  // init - set initial values
  @override
  void initState() {
    super.initState();
    // initial load
    _listFuture = updateAndGetList();
  }

  Future<List<Location>> updateAndGetList() async {

    var response = await http.get("http://XXX.XXX.XXX.XXX/api/listCompanies.php");
    if (response.statusCode == 200) {
      var r1 = json.decode(response.body);
      jsonResponse r = new jsonResponse.fromJson(r1);
      return r.locations;
    } else {
      throw Exception('Failed to load internet');
    }
  }

  void _toggleLocation(int index) {
    if (selectedLocationIndices.contains(index))
      selectedLocationIndices.remove(index);
    else
      selectedLocationIndices.add(index);
  }

  // build() method
  @override
  Widget build(BuildContext context) {

    return new FutureBuilder<List<Location>>(
        future: _listFuture,
        builder: (context, snapshot){

          if (snapshot.connectionState == ConnectionState.waiting) {
            return new Center(
              child: new CircularProgressIndicator(),
            );
          } else if (snapshot.hasError) {
            return new Text('Error: ${snapshot.error}');
          } else {
            final items = snapshot.data;
            return new Scrollbar(
              child: new RefreshIndicator(
                  child: ListView.builder(
                    physics: const AlwaysScrollableScrollPhysics(),
                    //Even if zero elements to update scroll
                    itemCount: items.length,
                    itemBuilder: (context, index) {
                      return LocationItem(
                        isSelected: selectedLocationIndices.contains(index),
                        onTap: () => setState({
                          _toggleLocation(index);
                        })
                      );
                    },
                  ),
                  onRefresh: () {
                    // implement later
                    return;
                  } // refreshList,
              ),
            );
          }// else
        } // builder
    ); // FutureBuilder
  } // build
} // locationsApiState class


class locationsApiWidget extends StatefulWidget {
  @override
  locationsApiState createState() => locationsApiState();
}

And the Item list entry:

class LocationItem extends StatelessWidget {

  final bool isSelected;
  final Function tapLocation;

  const LocationItem({@required this.isSelected, @required this.tapLocation, Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      color: isSelected ? HexColor('34bdeb') : HexColor('fc7303'),
      child: ListTile(
        title: Text(items[index].companyName),
        onTap: () => tapLocation() // onTap
      )
    );
  }
}

Forgive me, I can't compile it so I hope it's correct. But I think you get the idea: have the Stateful widget keep track of the selected locations separately, and let the location decide how to render itself when it's rebuilt.