A Star Rating widget for Flutter

Stefan Matthias Aust
ICNH
Published in
5 min readMar 24, 2019

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.

--

--

Stefan Matthias Aust
ICNH
Editor for

App Developer · Co-Founder of I.C.N.H GmbH · Pen & paper role player