Handling User Input — Flutter Tutorial Part 4
In the Landscape application, a user can flag their favorite places, and filter the list to show just their favorites. He / She can also give rating to the landscape. To create this feature, you’ll start by adding a switch to the list so users can focus on just their favorites, and then you’ll add a heart-shaped button that a user taps to flag a landscape as a favorite, and you will later add five star-shaped buttons that the user can tap to give his / her rating.
Let’s simply use the completed project from the previous tutorial to get started.
Mark the User’s Favorite Landscapes
Begin by enhancing the list to show users their favorites at a glance. Add two properties to the LandscapeModel
to read the initial state of a landscape as a favorite, and then add a heart-shaped button to each LandscapeItem
that shows a favorite landscape.
Add properties to LandscapeModel
Open the starting point Xcode project or the project you finished in the previous tutorial, and select landscapeModel.dart
in the Project navigator.
final double rating;
final bool favorite;
And then add these two final fields to the constructor:
LandscapeModel(this.name, this.country, this.park, this.assetPath, this.rating, this.favorite);
Then add these properties to the instance getModels
:
static List<LandscapeModel> get getModels {
return [
LandscapeModel("Lake Tekapo", "New Zealand", "Lake Tekapo Regional Park",
"assets/LakeTekapo.JPG", 4.8, false),
LandscapeModel("Mt Cook", "New Zealand", "Aoraki/Mount Cook National Park",
"assets/MtCook.JPG", 4.4, false),
LandscapeModel("Seville Plaza de España", "Spain", "Plaza de España",
"assets/Seville-Plaza-de-España.JPG", 4.4, false),
LandscapeModel("Whampoa Pagoda", "China", "Whampoa Pagoda",
"assets/WhampoaPagoda.JPG", 4.9, true),
];
}
Add rating bar to the item
In order to show the rating of the landscape, we need to add a rating bar containing 5 star-shaped icons.
A graphical icon widget drawn with a glyph from a font described in an IconData such as material’s predefined IconDatas in Icons. Icons are not interactive. For an interactive icon, consider material’s IconButton.
There must be an ambient Directionality widget when using Icon. Typically this is introduced automatically by the WidgetsApp or MaterialApp. This widget assumes that the rendered icon is squared. Non-squared icons may render incorrectly.
Here we are going to use the Material Icons to display the ratings:
Now create a new file ratingStar.dart
:
class RatingStar extends StatelessWidget {
final Rating state;
const RatingStar(this.state);
@override
Widget build(BuildContext context) {
return Icon(
state == Rating.zero
? Icons.star_outline
: state == Rating.half
? Icons.star_half
: Icons.star,
color: Colors.amber);
}
}
enum Rating { zero, half, full }
Enums are an essential part of programming languages. They help developers define a small set of predefined set of values that will be used across the logics they develop. Here we use a enum to return a specific star shape.
And the ratingBar.dart
:
class RatingBar extends StatelessWidget {
final double rating;
const RatingBar(this.rating);
@override
Widget build(BuildContext context) {
List<Widget> w = [];
double r = rating;
int i = 0;
while (i < 5) {
if (r >= 0.75)
w.add(RatingStar(Rating.full));
else if (r >= 0.25)
w.add(RatingStar(Rating.half));
else
w.add(RatingStar(Rating.zero));
r--;
i++;
}
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: w);
}
}
Let’s add the RatingBar
into LandscapeItem
:
...
Column(children: [
Text(model.name),
Text("${model.park} - ${model.country}"),
RatingBar(model.rating)
], crossAxisAlignment: CrossAxisAlignment.start)
...
Here we go. But the result is not so satisfied… We need to make some adjustments to beautify the layout. We will use TextStyle class to change the size and weight of the text and we will change the size of the icons.
return Icon(
state == Rating.zero
? Icons.star_outline
: state == Rating.half
? Icons.star_half
: Icons.star,
color: Colors.amber, size: 12);
And change the size and the weight of the Text
widgets:
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(4),
child: Row(children: [
RoundImage(
img: AssetImage(model.assetPath),
width: 48,
height: 48,
radius: 8),
SizedBox(width: 8),
Column(children: [
Text(model.name,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Row(children: [
Text("${model.park} - ${model.country}",
style: TextStyle(fontSize: 12)),
SizedBox(width: 4),
RatingBar(model.rating)
]),
], crossAxisAlignment: CrossAxisAlignment.start)
]));
}
That’s awesome!
And let’s add some widgets in the detail page:)
We are going to add a heart-shaped IconButton
for users to favourite the landscape. We will also add the RatingBar
into the detail page.
Bottom Container
We decide to make a bottom container in our detail page. Let’s start with creating bottomContainer.dart
. Because the heart-shaped button can be toggle we need to make this widget Stateful.
class BottomContainer extends StatefulWidget {
final LandscapeModel model;
const BottomContainer(this.model);
@override
State<StatefulWidget> createState() => BottomContainerState();
}
class BottomContainerState extends State<BottomContainer> {
@override
Widget build(BuildContext context) {
...
}
}
And add a Container
to the BottomContainer
:
return Container(
height: 120,
padding: EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.model.name,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Row(children: [
Text(widget.model.park + ", " + widget.model.country),
SizedBox(width: 8),
RatingBar(widget.model.rating)
])
]));
Awesome, but let’s give the Container a backgroundColor
and radiusCorners
with BoxDecoration
:
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(8))),
And we need to add a heart-shaped button to the right side of the information. Before adding any widgets to the page, we need to generate an instance for changing the value of favourite
in the LandscapeModel
:
void setFavorite() {
this.favorite = !this.favorite;
}
Wrap the Column
above with a Row
and add a Spacer
(Spacer
creates an adjustable, empty spacer that can be used to tune the spacing between widgets in a Flex
container, like Row
or Column
.) and a IconButton
:
return Container(
height: 120,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(4))),
child: Column(children: [
Row(children: [
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.model.name,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Row(children: [
Text(widget.model.park + ", " + widget.model.country),
SizedBox(width: 8),
RatingBar(widget.model.rating)
])
]),
Spacer(),
Expanded(
child: IconButton(
icon: Icon(
widget.model.favorite
? Icons.favorite
: Icons.favorite_border,
color: Colors.redAccent),
onPressed: () {
setState(() {
widget.model.setFavorite();
});
}))
])
]));
The instance setState
is called here to notify the framework that the internal state of this object has changed. Whenever you change the internal state of a State
object, make the change in a function that you pass to setState
:
setState(() { _myState = newValue; });
The provided callback is immediately called synchronously. It must not return a future (the callback cannot be async
), since then it would be unclear when the state was actually being set.
Calling setState
notifies the framework that the internal state of this object has changed in a way that might impact the user interface in this subtree, which causes the framework to schedule a build
for this State
object.
If you just change the state directly without calling setState
, the framework might not schedule a build
and the user interface for this subtree might not be updated to reflect the new state.
Ok, so let’s refresh our page:
Now when you press the IconButton
, the icon will automatically change because of the page is a StatefulWidget
.
Filter the List View
You can customize the list view so that it shows all of the landmarks, or just the user’s favorites. To do this, you will need to use .filter
instance of the List
.
First open landscapeModel.dart
and add a property index
:
...
int index;
...
LandscapeModel(this.index, this.name, this.country, this.park, this.assetPath, this.rating, this.favorite);
static List<LandscapeModel> get getModels {
return [
LandscapeModel(0, "Lake Tekapo", "New Zealand",
"Lake Tekapo Regional Park", "assets/LakeTekapo.JPG", 4.8, false),
LandscapeModel(1, "Mt Cook", "New Zealand",
"Aoraki/Mount Cook National Park", "assets/MtCook.JPG", 4.4, false),
LandscapeModel(2, "Seville Plaza de España", "Spain", "Plaza de España",
"assets/Seville-Plaza-de-España.JPG", 4.4, false),
LandscapeModel(3, "Whampoa Pagoda", "China", "Whampoa Pagoda",
"assets/WhampoaPagoda.JPG", 4.9, true),
];
}
Get back to listPage.dart
and change the widget ListPage
to StatefulWidget
. Add a ListPageState
class and move all the widgets inside the ListPage
and ListPageContent
widget to the ListPageState
.
// ListPageState
...
bool favoriteOnly = false;
List<LandscapeModel> models;
@override
Widget build(BuildContext context) {
models = LandscapeModel.getModels.where((e) {
return favoriteOnly ? e.favorite : true;
}).toList();
...
}
...
and add a property actions
in the Scaffold
’s appBar
:
appBar: AppBar(
title:
Text("Landscape List", style: TextStyle(color: Colors.black)),
actions: [
IconButton(
icon: Icon(
this.favoriteOnly ? Icons.clear_all : Icons.filter_alt,
color: Colors.black54),
onPressed: () {
setState(() {
this.favoriteOnly = !this.favoriteOnly;
});
},
)
],
brightness: Brightness.light,
backgroundColor: Colors.white,
centerTitle: false),
Then run the application:
We make it!
But we still need to store the data our users input. We need to determined whether the landscape is favorited the last time user open the application.
Store key-value data on disk
If you have a relatively small collection of key-values to save, you can use the shared_preferences
plugin.
Normally, you would have to write native platform integrations for storing data on both iOS and Android. Fortunately, the shared_preferences
plugin can be used to persist key-value data on disk. The shared preferences plugin wraps NSUserDefaults
on iOS and SharedPreferences
on Android, providing a persistent store for simple data.
This recipe uses the following steps:
- Add the dependency.
- Save data.
- Read data.
- Remove data.
1. Add the dependency
Before starting, add the shared_preferences
plugin to the pubspec.yaml
file:
dependencies:
flutter:
sdk: flutter
shared_preferences: "<newest version>"
2. Save data
To persist data, use the setter methods provided by the SharedPreferences
class. Setter methods are available for various primitive types, such as setInt
, setBool
, and setString
.
Setter methods do two things: First, synchronously update the key-value pair in-memory. Then, persist the data to disk.
// obtain shared preferences
final prefs = await SharedPreferences.getInstance();// set value
prefs.setInt('counter', counter);
Here we will apply changes to the data once our user updates his / her preferences:
void setFavorite() {
this.favorite = !this.favorite; final prefs = await SharedPreferences.getInstance();
prefs.setString('landscapes', ...);
}
Wait… how to save a List<LandscapeModel>
?
LandscapeModel.fromJson(Map<String, dynamic> json)
: index = json['index'],
name = json['name'],
country = json['country'],
park = json['park'],
assetPath = json['assetPath'],
rating = json['rating'],
favorite = json['favorite'];
Map<String, dynamic> toJson() => {
"index": index,
"name": name,
"country": country,
"park": park,
"assetPath": assetPath,
"rating": rating,
"favorite": favorite
};
static Future<List<LandscapeModel>> get getModels async {
List<LandscapeModel> models = [];
jsonDecode((await SharedPreferences.getInstance()).getString("landscapes"))
.forEach((e) {
models.add(LandscapeModel.fromJson(e));
});
return models;
}
@override
String toString() {
return '{"index": $index, "name": "$name", "country": "$country", "park": "$park", "assetPath": "$assetPath", "rating": $rating, "favorite": $favorite}';
}
And it works properly…? Ohh we need to set the default data into the SharedPreferences
.
class LandscapeApplication extends StatelessWidget {
@override
Widget build(BuildContext context) {
() async {
SharedPreferences sp = await SharedPreferences.getInstance();
if (sp.getString("landscapes") == null)
sp.setString(
"landscapes",
[
LandscapeModel(0, "Lake Tekapo", "New Zealand", "Lake Tekapo Regional Park", "assets/LakeTekapo.JPG", 4.8, false),
LandscapeModel(1, "Mt Cook", "New Zealand", "Aoraki/Mount Cook National Park", "assets/MtCook.JPG", 4.4, false),
LandscapeModel(2, "Seville Plaza de España", "Spain", "Plaza de España", "assets/Seville-Plaza-de-España.JPG", 4.4, false),
LandscapeModel(3, "Whampoa Pagoda", "China", "Whampoa Pagoda", "assets/WhampoaPagoda.JPG", 4.9, true),
].toString());
}();
...
}
And we have to change the build
of the ListPageState
and the DetailPage
ListPageState
return FutureBuilder<List<LandscapeModel>>(
future: LandscapeModel.getModels,
builder: (BuildContext context, AsyncSnapshot<List<LandscapeModel>> snapshot) {
if (snapshot.hasData) {
models = snapshot.data.where((e) {
return favoriteOnly ? e.favorite : true;
}).toList();
return Scaffold(
appBar: AppBar(
title: Text("Landscape List", style: TextStyle(color: Colors.black)),
actions: [
IconButton(
icon: Icon(this.favoriteOnly ? Icons.clear_all : Icons.filter_alt, color: Colors.black54),
onPressed: () {
setState(() {
this.favoriteOnly = !this.favoriteOnly;
});
},
)
],
brightness: Brightness.light,
backgroundColor: Colors.white,
centerTitle: false),
body: Container(
width: double.infinity,
height: double.infinity,
padding: EdgeInsets.symmetric(vertical: 8),
child: ListView.builder(
itemCount: models.length,
itemBuilder: (BuildContext context, int index) {
return InkWell(
child: LandscapeItem(models[index]),
onTap: () {
openDetailPage(context, models[index].index);
});
})));
} else
return Scaffold(body: SizedBox());
});
DetailPage
return FutureBuilder<List<LandscapeModel>>(
future: LandscapeModel.getModels,
builder: (BuildContext context,
AsyncSnapshot<List<LandscapeModel>> snapshot) {
if (snapshot.hasData) {
LandscapeModel model = snapshot.data[index];
return Scaffold(
body: Stack(children: [
Image(
width: double.infinity,
height: double.infinity,
image: AssetImage(model.assetPath),
fit: BoxFit.cover),
Positioned(
bottom: 0, left: 0, right: 0, child: BottomContainer(model))
]));
} else
return Scaffold(body: SizedBox());
});
It works properly!
Conclusion
Yes the application now can handle user’s input and save them!
Thanks for reading.