Planets-Flutter: planet detail page
After a while struggling on how to continue and how to put all the things I wanted to explain together I have decided to follow the KISSS methodolgy (Keep It Simple, Stupid Sergi) and continue with a simpler example. As the Planets tutorial was focused on UI, I didn’t want to include Stateful Widgets. Decided, we will continue with the planet detail screen.
Job description
We want to create a detail page to show expanded information about each planet (yes, I know, Moon is not a planet).
Defining Routing at application level
This is what we want to achieve
We will have four major components on this page:
- The background image
- The gradient between the background image and the background color
- The content itself, which should be scrollable
- The back button on top
So the basic class is easy:
1class DetailPage extends StatelessWidget {
2
3 final Planet planet;
4
5 DetailPage(this.planet);
6
7 @override
8 Widget build(BuildContext context) {
9 return new Scaffold(
10 body: new Container(
11 constraints: new BoxConstraints.expand(),
12 color: new Color(0xFF736AB7),
13 child: new Stack (
14 children: <Widget>[
15 _getBackground(),
16 _getGradient(),
17 _getContent(),
18 _getToolbar(context),
19 ],
20 ),
21 ),
22 );
23 }
We just define a new Scaffold
having only a body
with a Container that we use for two basic things:
set
BoxConstrains.expand()
as constraint. Once we have all the components, we can delete this, as the scroll for the content will already expand the content, but, while creating the background and the gradient, using this constraint will make the Container to use all the screen, instead being cut to the image size.set background color as 0xFF736AB7. As exercise to you, maybe is a good idea to group all colors in a single file as we did with text styles.
Inside we have an Stack, as all these four parts will be put one on top of the other. For the time being, comment all the functions but the first, so we can construct it step by step.
Background image
We will recover these images from The Internet. In the planets.dart
file (our simple model) we add a new picture
field to each object with an url to the corresponding image. You can copy these url from the repository.
All the images used are copyrighted from Nasa.
Now we need to put the image in place. What we want is the image to fit 300dp height and the whole screen width.
Simply we create the following function:
1 Container _getBackground () {
2 return new Container(
3 child: new Image.network(planet.picture,
4 fit: BoxFit.cover,
5 height: 300.0,
6 ),
7 constraints: new BoxConstraints.expand(height: 300.0),
8 );
9 }
First, we create a Container
with a constraint to expand as much as possible keeping a height of 300dp.
Second, we use Image.network
object. This constructor is a quick way to load an image from a url. We also setup the height to 300dp, and the fit parameter as BoxFit.cover
. This constraint ensures that the downloaded image will cover all the Widget with the minimum image possible, this will work whatever the orientation of the image. I suggest you play around with the different values of BoxFit
to understand how they work.
The current detail should look like this:
Background gradient
We want to add a nice gradient on top the image to transition from the picture to the background color. Uncomment the _getGradient()
line and copy the following code:
1 Container _getGradient() {
2 return new Container(
3 margin: new EdgeInsets.only(top: 190.0),
4 height: 110.0,
5 decoration: new BoxDecoration(
6 gradient: new LinearGradient(
7 colors: <Color>[
8 new Color(0x00736AB7),
9 new Color(0xFF736AB7)
10 ],
11 stops: [0.0, 0.9],
12 begin: const FractionalOffset(0.0, 0.0),
13 end: const FractionalOffset(0.0, 1.0),
14 ),
15 ),
16 );
17 }
This is very similar to the gradient we made on the toolbar chapter. We added a top margin to make it fit to the image bottom, realize that the top margin and the height add up to 300dp, the exact height of the image. We also use 0.0 and 0.9 as stops to make the gradient achieve the solid part at the 90%, as otherwise the border of the image was still slightly noticeable.
Now we have a smooother image integration into the background.
The content
Now the big part, the content itself. It will be scrollable on top of the image and the gradient, so out initial code will be like this:
1 Widget _getContent() {
2 return new ListView(
3 padding: new EdgeInsets.fromLTRB(0.0, 72.0, 0.0, 32.0),
4 children: <Widget>[
5 ],
6 );
7 }
This is a simple ListView
to make the content scrollable. We add a padding to 72dp on the top to left space for the image and 32dp at the bottom to add a nice list ending.
The content of the ListView has two main parts, the top card and the text itself. If we analyze the top card, we realize is veeery similar to the one we created for the main list. The biggest difference is that the planet thumbnail is at the top instead of being at the left, and some margins and alignments are different.
Could we reuse the same code that we made for the row? Sure. I’ve renamed the PlanetRow class to PlanetSummary. This is the resultant code with the changed lines different:
1class PlanetSummary extends StatelessWidget {
2
3 final Planet planet;
4 final bool horizontal;
5
6 PlanetSummary(this.planet, {this.horizontal = true});
7
8 PlanetSummary.vertical(this.planet): horizontal = false;
9
10 @override
11 Widget build(BuildContext context) {
12
13 final planetThumbnail = new Container(
14 margin: new EdgeInsets.symmetric(
15 vertical: 16.0
16 ),
17 alignment: horizontal ? FractionalOffset.centerLeft : FractionalOffset.center,
18 child: new Hero(
19 tag: "planet-hero-${planet.id}",
20 child: new Image(
21 image: new AssetImage(planet.image),
22 height: 92.0,
23 width: 92.0,
24 ),
25 ),
26 );
27
28 Widget _planetValue({String value, String image}) {
29 return new Container(
30 child: new Row(
31 mainAxisSize: MainAxisSize.min,
32 children: <Widget>[
33 new Image.asset(image, height: 12.0),
34 new Container(width: 8.0),
35 new Text(planet.gravity, style: Style.regularTextStyle),
36 ]
37 ),
38 );
39 }
40
41
42 final planetCardContent = new Container(
43 margin: new EdgeInsets.fromLTRB(horizontal ? 76.0 : 16.0, horizontal ? 16.0 : 42.0, 16.0, 16.0),
44 constraints: new BoxConstraints.expand(),
45 child: new Column(
46 crossAxisAlignment: horizontal ? CrossAxisAlignment.start : CrossAxisAlignment.center,
47 children: <Widget>[
48 new Container(height: 4.0),
49 new Text(planet.name, style: Style.headerTextStyle),
50 new Container(height: 10.0),
51 new Text(planet.location, style: Style.subHeaderTextStyle),
52 new Container(
53 margin: new EdgeInsets.symmetric(vertical: 8.0),
54 height: 2.0,
55 width: 18.0,
56 color: new Color(0xff00c6ff)
57 ),
58 new Row(
59 mainAxisAlignment: MainAxisAlignment.center,
60 children: <Widget>[
61 new Expanded(
62 flex: horizontal ? 1 : 0,
63 child: _planetValue(
64 value: planet.distance,
65 image: 'assets/img/ic_distance.png')
66
67 ),
68 new Container (
69 width: 32.0,
70 ),
71 new Expanded(
72 flex: horizontal ? 1 : 0,
73 child: _planetValue(
74 value: planet.gravity,
75 image: 'assets/img/ic_gravity.png')
76 )
77 ],
78 ),
79 ],
80 ),
81 );
82
83
84 final planetCard = new Container(
85 child: planetCardContent,
86 height: horizontal ? 124.0 : 154.0,
87 margin: horizontal
88 ? new EdgeInsets.only(left: 46.0)
89 : new EdgeInsets.only(top: 72.0),
90 decoration: new BoxDecoration(
91 color: new Color(0xFF333366),
92 shape: BoxShape.rectangle,
93 borderRadius: new BorderRadius.circular(8.0),
94 boxShadow: <BoxShadow>[
95 new BoxShadow(
96 color: Colors.black12,
97 blurRadius: 10.0,
98 offset: new Offset(0.0, 10.0),
99 ),
100 ],
101 ),
102 );
103
104
105 return new GestureDetector(
106 onTap: horizontal
107 ? () => Navigator.of(context).push(
108 new PageRouteBuilder(
109 pageBuilder: (_, __, ___) => new DetailPage(planet),
110 transitionsBuilder: (context, animation, secondaryAnimation, child) =>
111 new FadeTransition(opacity: animation, child: child),
112 ) ,
113 )
114 : null,
115 child: new Container(
116 margin: const EdgeInsets.symmetric(
117 vertical: 16.0,
118 horizontal: 24.0,
119 ),
120 child: new Stack(
121 children: <Widget>[
122 planetCard,
123 planetThumbnail,
124 ],
125 ),
126 )
127 );
128 }
129}
The changes applied to the class to make it reusable are highlighted. Let’s review them:
line 6-7: now we have two constructors. The usual one that sets
horizontal
as true, and another one namedPlanet.vertical
that sets the value as false. We will use this boolean to know what to do in the whole class.line 17: simply align the image to the left or to the center depending on the value of horizontal.
line 35: we added a simple container of 8dp width to make the separation a bit larger, otherwise the two values look too close when centered.
line 43: depending on boolean, the card content will have a left margin of 76dp or a top margin of 42dp, and 16dp for all other values.
line 46: another ternary operator to decide if items on the card should align left or center.
lines 62 and 72: the flex parameter makes the items to expand as much as possible or to the content. In the case of horizontal, we want them to be in the left half and in the right half, but for the vertical one, that looks ugly.
lines 86 to 89: this is were half of the tricks are done. We setup different margins and height depending on the orientation. Try to play around with the numbers.
lines 106 to 114: as we do not want the card in the detail to be clickable, we set the onClick callback to null if horizontal is false.
And now we can left the invocation from the list as it is (because the default constructor sets horizontal
to true) and in we just need to add a new PlannetSumary.vertical(planet)
to our ListView.
There is still room for improvement, like moving the navigation code out of the component, moving all the ternary operators to values defined in the constructors, etc. But I left this as an exercise.
Another small reuse we can do is to convert the blue separator to a Widget as we will reuse in the content. That’s a simple one:
1class Separator extends StatelessWidget {
2 @override
3 Widget build(BuildContext context) {
4 return new Container(
5 margin: new EdgeInsets.symmetric(vertical: 8.0),
6 height: 2.0,
7 width: 18.0,
8 color: new Color(0xff00c6ff)
9 );
10 }
11}
Finally, we can construct the whole list. It will look as this:
1 Widget _getContent() {
2 final _overviewTitle = "Overview".toUpperCase();
3 return new ListView(
4 padding: new EdgeInsets.fromLTRB(0.0, 72.0, 0.0, 32.0),
5 children: <Widget>[
6 new PlanetSummary(planet,
7 horizontal: false,
8 ),
9 new Container(
10 padding: new EdgeInsets.symmetric(horizontal: 32.0),
11 child: new Column(
12 crossAxisAlignment: CrossAxisAlignment.start,
13 children: <Widget>[
14 new Text(_overviewTitle, style: Style.headerTextStyle,),
15 new Separator(),
16 new Text( planet.description, style: Style.commonTextStyle),
17 ],
18 ),
19 ),
20 ],
21
22 );
23 }
Basically, we declare a variable for the “Overview” title and we fill the ListView
with two widgets: a PlanetSummary
and a Container
. We use the container to give a 32dp padding in both sides. We put a Column
inside. We use a crossAxisAlignment of start
to align it to the left (or right in RTL language) and put the two texts and the separator inside. Easy peasy.
Check the repository for the text styles as I did a bit of refactor of the TextStyles class.
The result right now is this:
Back button
Last part is simple, add a back button, something we already did for our original toolbar.
1 Container _getToolbar(BuildContext context) {
2 return new Container(
3 margin: new EdgeInsets.only(
4 top: MediaQuery
5 .of(context)
6 .padding
7 .top),
8 child: new BackButton(color: Colors.white),
9 );
10 }
As you can see is just a Container
with a BackButton
. We force the white color to the button, and we add a margin from MediaQuery
to put under the notification bar.
That’s all folks!
Now your detail should look as intended. Check the final result at the repository at branch Lesson_6_planets_Flutter_planets_detail
To NOT be continued
So, this is the end!
I’ve made the Planets app to grow up to the original idea I had in mind. Of course there is still for improvement, but as this tutorial was intended to explain UI things, is good to finish it here and move to new examples more suitables to explain other things. Firebase integration? Backend access? MVP? Redux? who knows… but I have some ideas. Also, I want to write some short posts about specific Flutter things and mix the Flutter content with other things (mainly Google Assistant with DialogFlow, my other toy at this moment).
A big thanks to all of you who followed this tutorial up to this point. I’ve seen some of you create other apps using this tutorial as template, keep working and please, show me your results!
And again, a big thanks to Vijay Verma for his great design that inspired me.
Stay tuned for more tutorials and articles!