12.2 API Calls Continued
# Http and Multidimensional Data
When you get JSON data from an API call, it is rare that you will get one simple object like {"id":1, "title":"hello"}
. Most of the results that you get will contain arrays and other two dimensional
data.
The way we access those properties are very much like how we would access data in nested objects and arrays in JavaScript. Simply use the square bracket notation with integers data[0]
for arrays and
with property names for objects (Maps) data['property_name']
.
Using this data as an example set:
{
"timestamp": 1500123400987000,
"results": [
{
"id": 123,
"artist": "Green Day"
},
{
"id": 456,
"artist": "Pink"
},
{
"id": 789,
"artist": "Volbeat"
}
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
This is how we would get some of the example properties.
Future<Map> getData(String keyword) async {
Map<String, dynamic> params = {
'apikey': 'asdkjskdfjhskhfksdjh',
'keyword': 'music',
}
Uri uri = Uri.https(domain, path, params);
http.Response response = await http.get(uri); //http get request
Map<String, dynamic> data = jsonDecode(response.body);
//convert the json String in the response body into a Map object with String keys.
print( data['timestamp'] ); //1500123400987000
print( data['results'][0]['id']); //123
print( data['results'][1]['artist']); //Pink
print( data['results'][2]['artist']); //Volbeat
//use a for loop to output all the artist names
int len = data['results'].length;
for (int i = 0; i < len; i++) {
print( data['results'][i]['artist'] );
}
//use a forEach method call to output all the id and artist values
data['results'].forEach( getArtist );
}
void getArtist(Map<dynamic, dynamic> artist){
print( '${artist["id"]} ${artist["artist"]}');
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Http, State and ListView
When you retrieve data with an http
get
or post
call, this will often be used to create the items inside of a ListView
. You can simply pass your data variable to the ListView.builder
to
create the list. However, your list may need to be updated. Maybe items will be removed. Maybe a refresh action will get a new version of your data. Whatever the reason, your ListView
items may need
to be updated.
To be able to update the widgets in your current screen, we will need to put your data into a State
variable and then use that State
variable as the data source for the ListView.builder
. By
doing this, it means that any time setState
gets called, it will be the trigger to update the rendering of the ListView
widget tree by running the build function again.
Here is the full repo for using State and Http with a ListView.builder.
//A ListView widget built using data from a state variable.
// do a fetch call to get the data and build the list after the data has arrived.
class MainPage extends StatefulWidget {
MainPage({Key? key, required this.title}) : super(key: key);
final String title; //we can pass things into our widgets like this
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
//entries will be populated with API data
final List<String> stuff = <String>['one text', 'two text', 'three text'];
late List<User> users = <User>[];
void initState() {
//Think of the initState like react useEffect() for initial load
super.initState();
//go get the user data
getData();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title), //widget.title gets title from MainPage
centerTitle: true,
),
body: Column(
children: [
Expanded(
flex: 1,
child: ListView.builder(
padding: EdgeInsets.all(16.0),
itemCount: stuff.length,
itemBuilder: (BuildContext context, int index) {
//gets called once per item in your List
return ListTile(
title: Text(stuff[index]),
);
},
),
),
Divider(
color: Colors.black38,
),
Expanded(
flex: 2,
//ternary operator for empty list check
//using List.isNotEmpty rather than List.length > 0
child: users.isNotEmpty
? ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: users.length,
itemBuilder: (BuildContext context, int index) {
//gets called once per item in your List
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.amber,
child: Text('A'),
),
title: Text('${users[index].name}'),
subtitle: Text(users[index].email),
);
},
)
: ListTile(
title: const Text('No Items'),
),
),
],
),
);
}
//function to fetch data
Future getData() async {
print('Getting data.');
HttpHelper helper = HttpHelper();
List<User> result = await helper.getUsers();
setState(() {
users = result;
print('Got ${users.length} users.');
});
}
//function to build a ListTile
Widget userRow(User user) {
Widget row = ListTile(
title: Text(user.name),
subtitle: Text(user.email),
);
return row;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
The example above uses a state variable late List<User> users = <User>[];
that holds a default value of an empty list of User
objects. The User
object is defined in /lib/data/user.dart
as a
class that has an instance variable for each property we want a user to have. The class declares the variables, has a standard constructor and a custom constructor that will build a User
object from
a Map<String, dynamic> userMap
being passed from from the fetched JSON data.
There is also an HttpHelper
class in /lib/data/http_helper.dart
. It does the actual fetch call to the API, accepts the JSON string, converts the JSON string into a List<dynamic>
, and then loops
through the list and calls on the User
class to convert the elements in the List
from Map<String, dynamic>
to User<String, dynamic>
.
Inside the 2nd Expanded(child:)
note the use of the ternary operator as the value.
# Cookbook version without state
The cookbook recipe for fetching data shows how to add a FutureBuilder
as a widget in the widget tree. Let's say that we have an Expanded
element that contains a ListView
. The ListView
will eventually hold the results of an http.get
command.
Expanded(
child: FutureBuilder<Stuff>(
future: futureStuff,
builder: (context, snapshot) {
if (snapshot.hasData) {
return MyListView(snapshot.data); //custom ListView.builder widget to show the data from snapshot
} else if (snapshot.hasError) {
return Text('${snapshot.error}');
}
// By default, show a loading spinner.
return const CircularProgressIndicator();
},
),
),
2
3
4
5
6
7
8
9
10
11
12
13
14
15
The future
property is given a function futureStuff
that will do the fetching of the data.
Future<Stuff> fetchAlbum() async {
final response = await http.get(Uri.parse('https://example.com/endpoint'));
if (response.statusCode == 200) {
return Stuff.fromJson(jsonDecode(response.body));
}else{
throw Exception('Failed to load album'); //populates the snapshot.hasError and snapshot.error properties
}
}
//our custom data object
class Stuff {
//declare variables
//create default constructor
Stuff({
//default props
});
//create custom constructor for from json
Stuff.fromJSON(Map<String, dynamic> json){
return Stuff(
//with props
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
This way we are effectively doing the same thing as we do with our state variable List in the first example.
Here is the reference for CircularProgressIndicator
circularProgressIndicator Widget of the Week
# Notes on Performance
When you are fetching data from an API and the API returns a large JSON file, it can sometimes take a while to actually parse the JSON string and convert it into a Map
or List
. If it takes longer
than 16ms (one frame) then the user may experience what is called Jank. Jank is visual interruptions in the interface. Maybe an animation stops and starts, maybe a button is unresponsive, maybe a
transition stalls...
To avoid problems like this, we can move the actual computation of the conversion from JSON to Map into a new thread by wrapping our function call inside a compute()
method call.
Here is the guide to using compute with JSON conversion.
# Uploading Data
When making a call to an API that needs headers or data to be uploaded that is not in the querystring, we can add the optional parameters in our calls to http.get
or http.post
, etc.
Future<http.Response> createAlbum(String title) {
return http.post(
Uri.parse('https://jsonplaceholder.typicode.com/albums'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{
'title': title,
}),
);
}
2
3
4
5
6
7
8
9
10
11
The default first argument for post
or get
is the Uri
. After that come the optional named parameters - headers
and body
. The headers
is a Map<String, String>
and the body is a
Map<String, dynamic>
that will be converted into a JSON string with a call to jsonEncode()
.
When working with JWT you will need to pass the token string inside a header, inside the headers
property.
Most API calls to post
or put
or patch
will require a body
value.
# Loading and Accessing Bundle Assets
In the pubspec.yaml
file under the flutter: assets:
section we usually load a list of image files that we want to be able to load through AssetImage()
or Image.asset()
. However, we can also
load properties, settings, and string values that we want to use in our app by including a JSON file in that list.
If you want to access the files loaded through the pubspec.yaml
file we can do it through a couple ways. There is a property called rootBundle
which will access those files. The rootBundle
property is located in the package:flutter/services.dart
package.
This code sample shows how you can access a JSON file called config.json
inside a folder called /assets/
.
import 'package:flutter/services.dart' show rootBundle;
Future<String> loadAsset() async {
return await rootBundle.loadString('assets/config.json');
}
void main() {
loadAsset().then((value) {
Map map = json.decode(value);
map['key2'] = 'value2';
print(map);
});
}
2
3
4
5
6
7
8
9
10
11
12
13
Then you can use the jsonDecode()
method, imported from dart:convert
, to read the contents of the file.
The rootBundle
represents all the files under that assets
heading in the pubspec. Another way to access the JSON data file is with DefaultAssetBundle
class and it's of
method. It uses the
current context
to access the value of the bundle as it has been passed down through the element tree.
import 'package:flutter/services.dart';
Future<String> loadAsset(BuildContext context) async {
return await DefaultAssetBundle.of(context).loadString('assets/config.json');
}
2
3
4
5
The reason you would use this second approach would be that you would want to include any updates to the bundle that have been made further up the element tree.
There is a base abstract class that is used for handling the assets, called AssetBundle
. rootBundle
is an implementation of the AssetBundle
class, and so is the DefaultAssetBundle
hook to the
rootBundle
. Additionally, there are two other classes that implement AssetBundle
- NetworkAssetBundle
and CachingAssetBundle
.
The NetworkAssetBundle
class and the CachingAssetBundle
class both have load()
and loadString()
methods (plus a few others) which can load files. The primary difference is that the caching
version's loadString
method will save the loaded data in a cache to be kept for the lifetime of the app. Both methods also have a clear()
method to clear everything cached or loaded, plus a
evict(String key)
method to remove a single peice of loaded data.
# What to do this week
TODO Things to do before next week
- Complete Flutter App 2
- Read all the notes for modules 12.1, 12.2, 13.1