A Star Rating widget for Flutter
Let’s design a widget to display the usual “3 out of 5 stars” rating.
Update: Here is a codepen with a working example.
Star Display
I use a Row
widget with minimal sizing for its main axis that shows five Icon
widgets, displaying either a filled star or an outlined star.
Here is the code:
class StarDisplay extends StatelessWidget {
final int value; const StarDisplay({Key key, this.value = 0})
: assert(value != null),
super(key: key); @override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (index) {
return Icon(
index < value ? Icons.star : Icons.star_border,
);
}),
);
}
}
And here is the result of StarDisplay(value: 3)
:
Customizing the Display
But what if we want to customize the icon color or icon size? We could add more parameters to the StarDisplay
constructor and pass down the values to the build
method. Or we could make use of a an IconTheme
widget like this:
IconTheme(
data: IconThemeData(
color: Colors.amber,
size: 48,
),
child: StarDisplay(value: 3),
),
Unfortunately, we cannot change the size and still provide our own default color this way because The IconThemData
will automatically default to IconThemeData.fallback.color
which is black.
Because of this and because Icons.star
and Icons.star_border
belong to the Material package, it might be a good idea to further abstract the StarDisplay
and drop this dependency by providing two child widgets to use for the filled star and the unfilled star like so:
class StarDisplayWidget extends StatelessWidget {
final int value;
final Widget filledStar;
final Widget unfilledStar; const StarDisplayWidget({
Key key,
this.value = 0,
@required this.filledStar,
@required this.unfilledStar,
}) : assert(value != null),
super(key: key); @override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (index) {
return index < value ? filledStar : unfilledStar;
}),
);
}
}class StarDisplay extends StarDisplayWidget {
const StarDisplay({Key key, int value = 0})
: super(
key: key,
value: value,
filledStar: const Icon(Icons.star),
unfilledStar: const Icon(Icons.star_border),
);
}
Here is how to use this lower-level widget:
StarDisplayWidget(
value: 2,
filledStar: Icon(Icons.android, color: Colors.green, size: 32),
unfilledStar: Icon(Icons.android, color: Colors.grey),
)
Still, I think there is such a thing as too much abstraction and depending on the project, it might be more pragmatic to incorporate the way to display the “stars” directly into the StarDisplay
widget. Depending on Material Design isn’t such a bad thing, even for apps intended to run on iOS only.
Star Rating
Let’s now explore how to make the widget interactive.
Right now, the icons are too small to touch them. A Material Design IconButton
should automatically use the recommended minimal size of 48 points. By making the icons a bit larger, it still looks okay, I think. And because I can, I changed the icon color to the material theme’s accent color and added a tooltip
to each icon which should improve accessibility.
class StarRating extends StatelessWidget {
final int value;
final IconData filledStar;
final IconData unfilledStar; const StarRating({
Key key,
this.value = 0,
this.filledStar,
this.unfilledStar,
}) : assert(value != null),
super(key: key); @override
Widget build(BuildContext context) {
final color = Theme.of(context).accentColor;
final size = 36.0;
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (index) {
return IconButton(
onPressed: () {},
color: index < value ? color : null,
iconSize: size,
icon: Icon(
index < value
? filledStar ?? Icons.star
: unfilledStar ?? Icons.star_border,
),
padding: EdgeInsets.zero,
tooltip: "${index + 1} of 5",
);
}),
);
}
}
We can get creative now:
To make the StarRating
interactive, I add an onChanged
callback function and link it to the icon button’s onPressed
callback. This way, a user of the StarRating
widget gets notified if a star it tapped and can modify some kind of model which then should somehow trigger a redisplay of the widget with a different value
.
class StarRating extends StatelessWidget {
final void Function(int index) onChanged;
final int value;
... return IconButton(
onPressed: onChanged != null
? () {
onChanged(value == index + 1 ? index : index + 1);
}
: null,
...
Notice the value == index + 1 ? index : index + 1
line. This way it “feels” right. When pressing a filled or an unfilled star, the row fills up to including this star. However, when pressing the last filled star in a row, it is toggled. This way we can rate even for zero stars.
Also notice that by passing null
to onPressed
, the button automatically becomes disabled, that is, the whole StarRating
widget is disabled without an onChanged
callback and enabled otherwise like any other button.
To keep track of the value, we need to create a stateful widget like so:
class StatefulStarRating extends StatelessWidget {
@override
Widget build(BuildContext context) {
int rating = 0;
return StatefulBuilder(
builder: (context, setState) {
return StarRating(
onChanged: (index) {
setState(() {
rating = index;
});
},
value: rating,
);
},
);
}
}
Here is also an example how to use the rating component together with a Property
instance (see article The Property Builder) to keep track of the current rating:
final property = Property(2);...PropertyBuilder(
property: property,
builder: (context, value) => StarRating(
onChanged: (v) => property.value = v,
value: value,
),
)
Form Field Star Rating
Last but not least, here’s an example how to wrap the StarRating
as a FormField
to use it as part of a Form
.
Actually, the most difficult part was to determine the TextStyle
for formatting the error message in a way similar to how a TextFormField
works. Otherwise, the mapping is straight forward. The FormFieldBuilder
creates a Column
that combines the rating widget (based on the current value
) and the optional error message. The widget’s onChanged
callback function is then wired to the state’s didChange
method.
I also added a stupid validator complaining about too low ratings, just to demonstrate this function. This, of course, could and should be parameterized.
FormField<int>(
initialValue: 3,
autovalidate: true,
builder: (state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
StarRating(
onChanged: state.didChange,
value: state.value,
),
state.errorText != null
? Text(state.errorText,
style: Theme.of(context)
.textTheme
.caption
.copyWith(
color: Theme.of(context).errorColor)
)
: Container(),
],
);
},
validator: (value) => value < 2 ? 'rating too low' : null,
);
And that’s all for today. Here is the complete source code.