Positive This is a continuation of Flutter Codelab / Coding Roulette #1. Please complete lab #1.
For the Coding Roulette Session:
We'll be creating the list page in order to display all data items in a scrollable list fashion, when the user gets brought into this page from the SplashPage we created on the previous lab.
Here's the schematic view of the list page we'll be tackling:
Let's start by laying down the foundation for this app, and edit the way the Scaffold widget looks. Set the backgroundColor property of the Scaffold widget to the mainThemeColor provided above, as follows:
Your code should look like this after running it on DartPad:
Set the AppBar widget to the Scaffold widget in the ListPage. Add the following specs:
Your code should look as follows:
Since everything in Flutter is a widget, you can add any widget as the title property of the AppBar widget, not just a Text widget. Let's set the title as an Icon widget, using the Icons.pool icon with a color of white:
The AppBar has a property you can use to add additional actions and functionality to it. We'll add a mocked action item to the AppBar widget (no functionality, just a placeholder), just to show you how you can utilize the AppBar widget's actions property.
We'll add a Container widget with 15px of right margin, wrapping a simple Icon widget, color white, as follows:
After running it on DartPad, you should see it as output:
And with that, we've set up the AppBar for this page! Let's keep rolling!
Now let's focus on the body property of the Scaffold widget, where the ListView will reside.
Let's start by replacing the existing placeholder content inside the body of the Scaffold widget (a Center widget with a Text widget) by a simple Container widget. Use the Container's decoration property to set its background color to white, as well as adding top left and top right borders of 50px radius to it:
Run it on DartPad so you can see the following output - a white background as the main body, with rounded corners at the top:
Looking pretty nice! Now let's fill up this container with the rest of the content.
Now it's time to lay down the ListView widget, which will display all our data in a vertically scrollable container.
But where will we get the data to populate this list? We'll create custom data models based on Dart classes, or what's commonly known as "Plain Ol' Dart Objects" or PODOs.
We need to create the corresponding class that will hold the data fields for the information of each of the attractions that we want to display. We'll call this class Attraction and we'll add the following properties:
Add a constructor as well to inject corresponding values during instantiation, and your class should look as follows (add it at the bottom of this file if you want - as long as it's in scope / inside the file - copy / paste the code below):
class Attraction {
String? imgPath;
String? name;
String? desc;
double? price;
String? location;
int? rating;
Attraction({this.imgPath, this.name, this.desc, this.price, this.location, this.rating });
}
With the Attraction PODO in place, we'll also provide some mocked data we'll use later to feed it into the ListView to render sample items for displaying purposes. At the top of the file, add the following List of AttractionModel objects - called attractionsList, each of which has its required properties populated accordingly (preferably to add it at the top, below the mainThemeColor where all constants and hardcoded values go - copy/paste for convenience):
final List<Attraction> attractionsList = [
Attraction(
imgPath:
'https://cf.bstatic.com/xdata/images/hotel/max1024x768/275162028.jpg?k=38b638c8ec9ec86624f9a598482e95fa634d49aa3f99da1838cf5adde1a14521&o=&hp=1',
name: 'Grand Bavaro Princess',
desc: 'All-Inclusive Resort',
location: 'Punta Cana, DR',
rating: 3,
price: 80.0),
Attraction(
imgPath:
'https://cf.bstatic.com/xdata/images/hotel/max1024x768/232161008.jpg?k=27808fe44ab95f6468e5433639bf117032c8271cebf5988bdcaa0a202b9a6d79&o=&hp=1',
name: 'Hyatt Ziva Cap Cana',
desc: 'All-Inclusive Resort',
price: 90.0,
rating: 4,
location: 'Punta Cana, DR'),
Attraction(
imgPath:
'https://cf.bstatic.com/xdata/images/hotel/max1024x768/256931299.jpg?k=57b5fb9732cd89f308def5386e221c46e52f48579345325714a310addf819274&o=&hp=1',
name: 'Impressive Punta Cana',
desc: 'All-Inclusive Resort',
price: 100.0,
rating: 5,
location: 'Punta Cana, DR'),
Attraction(
imgPath:
'https://cf.bstatic.com/xdata/images/hotel/max1024x768/283750757.jpg?k=4f3437bf1e1b077463c9900e4dd015633db1d96da38f034f4b70a4ba3ef76d82&o=&hp=1',
name: 'Villas Mar Azul Dreams',
desc: 'All-Inclusive Resort',
price: 100.0,
rating: 4,
location: 'Tallaboa, PR'),
];
With all of this in place (PODO classes, mocked data) let's proceed and add the ListView widget.
Back in the ListPage widget, inside the main Container widget, set its child property as a Column widget, with its *crossAlignment property set to CrossAlignment.stretch in order to make the items in the column stretch widthwise and fill the screen:
We want our ListView widget to occupy most of the real estate of its parent Column widget, so we'll wrap it inside an Expanded widget. We'll pretty much wrap everything inside this Expanded widget, which tells its parent (the Column widget that contains this custom widget) that the widget inside it will occupy most of the column's space; the rest of the widgets will only get the space allocated to them based on their dimensions.
The Expanded widget must have a child, so let's proceed by adding the next item in the structure: a ListView widget.
Let's add as the immediate child of the Expanded widget a ListView widget, which provides a scrollable list of widgets arranged linearly.
There are many ways in which you can instantiate a ListView widget, but we'll use a convenient constructore called builder, which creates a scrollable, linear array of widgets that are created on demand.
Our ListView.builder constructor takes several parameters, but we'll only use the minimum required parameters, as follows:
The itemBuilder callback returns the index value of the corresponding item being rendered as the user scrolls. Inside of this callback, you must return a Widget corresponding to each item to be rendered per list item.
As a test, let's return a Text widget, and let's feed it an item in the attractionsList collection, corresponding to the index provided in the callback. We'll hold this item in a property called attr, as follows:
Running it on DartPad up to this point, we should see the following output:
We see some values being rendered per item on the list - thanks to the mocked data provided earlier!. Now it's time to give more definition to each of those items by creating some sort of card widget to display its full info.
For each of the items displayed by the ListView we must create a unique layout and structure, therefore this lends itself to creating a new specialized widget for displaying its information. We'll create a custom widget called AttractionCard that will display the attraction information in a card-like fashion. See the schematics view of our custom card widget below:
As customary, create the Widget class corresponding to the AttractionCard widget - make it extend StatelessWidget and have it return a dummy Container from its build method for the time being (you can add this class at the bottom of the page):
This widget, in order to come alive, needs to have its required data injected. For this widget to receive data, let's create a property of type Attraction and a corresponding constructor for it:
This data will be injected via its constructor inside the ListView's *itemBuilder callback method, so let's go back to the ListPage and inside the itemBuilder callback, replace the existing return statement by returning a new instance of AttractionCard widget, passing the corresponding attraction during the iteration, as follows:
Let's go to our newly added AttractionCard widget and start defining the structure for it.
Start by adding a fixed height to our container of 300px. We want to constrain every attraction card to be this height so as to keep consistency heightwise.
As the only child of this fixed-height Container, add a Stack widget. We'll use the Stack to overlay items as we place them in it.
Add a Column widget with two additional Container widgets as the first child of the Stack widget above.
The reason why we have the two Container widgets inside the Column widget is because the top Container will be the host of the image (background image) and the bottom Container will hold the text information of this card.
We'll use the top Container widget in the Column to display the attraction image as a background image. First, set the container to a fixed 150px
Now, use the decoration property of the container to set a background image, by setting its image property to a DecorationImage, and the DecorationImage's image property to a NetworkImage widget. Use the model supplied (attraction) and fetch the image's path via the imgPath property.
On the DecorationImage widget, use the fit: BoxFit.cover in order to fill the Container while maintaining its aspect ratio:
Running it on DartPad up to this point, you should see the images start showing in the Preview Pane:
I believe we're ready to work on the bottom piece of the attraction card now. Proceed to the next step.
Now, let's focus on the bottom portion of the AttractionCard widget, where we'll add the text content to be displayed. Here's a schematics view of the bottom portion:
On the bottom Container widget, set it also to be a fixed height (150px), and add a 20px padding all around it, as such:
As the only child of this Container, add a Row widget, since the first level of items will be placed horizontally, spreaded apart by space in between dictated by the system, so set its *mainAxisAlignment property to MainAxisAlignment.spaceBetween:
Following up on the layout described above, now the Row should contain two Column widgets as its children, with their corresponding children property empty as well, as follows:
Focusing on the content of the left Column of the bottom Container, let's dissect it first and see what we're up against:
On the Column widget in question, add the first children Text widget, with the following specs:
Next up, the widgets containing the location pin and the location information for this attraction. Set this to be a Row widget, containing an Icon widget as the first child. Use the Icons.pin_drop Material Icon. Set its color to grey with a 70% opacity:
Add another Text widget displaying the location information - with font size 12px, bold and grey in color with 70% opacity. Add a SizedBox widget to simulate some spacing in between the Icon and the Text widget.
Take a pause to run the existing code in DartPad so you can start seeing the content coming to fruition in the Preview Pane:
Let's proceed now to tackle the RatingWidget widget. Click Next to continue.
Let's create a custom widget that will encapsulate the rating functionality.
Right under the last Row widget we added earlier, inside the left Column, add a placeholder RatingWidget widget. You will get an error since we haven't created it yet, but we will in a bit. For now, add it as follows:
Now let's create the class that will hold the rating functionality. Let's call it RatingWidget, make it extends StatelessWidget. In its build method, return a dummy Row widget - this is to lay down the foundation for our widget:
Since this widget will encapsulate the rating functionality, let's inject the value of the rating via the constructor. Create a property called rating as well as its corresponding constructor:
Add as the first child of this Row widget, yet another row, as follows:
This Row widget will be the one that will hold the stars together. The parent Row widget will then lay out both the star group and the label on the right in a horizontal fashion.
In order to generate the stars, we'll use a very useful utility method from the List class called generate. This factory method takes two parameters: the length of the list (which we'll hardcode to 5 for now) and a callback, which will be executed for each of the items in the collection, and will pass an index into the callback - the index corresponding to each item in the iteration. Assign this to the children property of the Row widget.
Then, inside the List.generate callback, return an Icon widget, which will be generated base don how many items we set in the length parameter (5). If the provided index is less than the rating value, then we will display a filled star (i.e. render a full star while iterating through the 5 items, the index falls under the rating) otherwise show it as an outlined star icon. Check out the code:
Let's see how's looking. Now, back in the AttractionCard widget, go to where we placed the RatingWidget placeholder widget, and inject into it the rating value from the attraction model, using the named parameter syntax:
If you run it on DartPad now, you will start seeing the rating stars showing, with their correct amount of filled and unfilled stars. Nice!
Let's wrap this up by adding the star rating label right next to the row of stars and doing some alignment.
Back to the RatingWidget, below the Row containing all stars, add some spacing by using a SizedBox with a width of 5:
Now, let's proceed and add the label that will show the rating number out of the max number of stars. For now we have it hardcoded at 5, but the rating value will be dynamic. Add it as a Text widget, with a 12px font size, color gray with 70% opacity:
I believe we're pretty much done with the RatingWidget widget, as we created a very encapsulated widget that represented the rating information.
Back to the AttractionCard widget, in the Column widget housing the card left content, add spaces in between, so they have some room to breathe. Use the same SizedBox trick and add spaces of 5px of height in betwen them, as such:
Now, apply some alignment to the items inside the Column by left-aligning all of them, usign the *crossAxisAlignment property and set it to CrossAxisAlignment.start:
Running on DartPad once more, and now we should see our full blown RatingWidget with the rest of the content left-aligned, as per the design:
Let's work now on the right hand side of the AttractionCard which should be pretty straightforward. Let's go to the next step.
We'll be focused on creating the right hand side of the AttractionCard widget content, which will consist of a Column widget with two Text widgets inside, aligned at the bottom and right edge of the column.
Back in the AttractionCard widget, we'll put our focus now on the second Column widget from above. Start by setting its alignment to bottom / right by setting its crossAxisAlignment to CrossAxisAlignment.end and its mainAxisAlignment to MainAxisAlignment.end respectively, as such:
Now, as its children, start by adding a Text widget for the price information, color black, font size 16px and bold. For displaying the price property in a Text widget, use the factory method *toStringAsFixed, passing the number of decimal places after the value. See the code below:
Add the next label, which will only show the hardcoded text "Per night", in gray color with 70% opacity, font size 12px and bold:
Add some space in between the Text widgets by using the SizedBox trick from before, setting a height of 5px in between them:
And running the app on DartPad up to this point will show you the whole bottom piece of the attraction card already in place! We're on a roll!
Let's now add the floating favorite button, as per in the design. Click Next to proceed to the next step.
We'll implement the floating favorite button as another child on the Stack widget, placed on top of all items, and aligned right / center of the overall container, so let's proceed.
Back again on the AttractionCard's Stack widget, let's start by adding a Container widget with fixed dimensions, with margin only on the right side of 10px:
This Container widget now will have a background color and rounded edges so as to make it look like a circle. Proceed to set its decoration property to a BoxDecoration object, to which you'll set its color to the mainTheme color, border radius of 40px:
To give it some depth, apply a box shadow to the Container's decoration, with a blur radius of 10px, use the mainThemeColor as its color, and setting its offset to Offset.zero, as follows:
Now you need to add a child to this container: the favorite icon!
As the child of this Container widget, set it to be an Icon widget, using the Icons.favorite icon, setting it to white, with a size of 15:
If you try to preview it now by running it on DartPad, you will notice the item shows at the top left corner of the Stack - because that's the default location for items added to the Stack (0,0), unless you apply an alignment to them. Here comes the Align widget to the rescue!
Wrap the Container widget around an Align widget, and set its alignment property to Alignment.centerRight, as per our design:
Taking it for a spin one more time on DartPad by running it, and now we see the floating fav button showing in its rightful place - center and right of the attraction card!
Now let's put the final touches on this AttractionCard widget by adding the rounded corners and shadow to it shall we? Click Next to proceed.
For the final touch on the AttractionCard, we'll apply some aesthetics and add rounded edges and a sense of depth by adding a shadow to it. We'll use a ClipRRect widget for clipping the contents of this widget, as well as wrapping the whole thing inside a Container to then apply a shadow to it. Let's proceed.
Wrap the root Container widget of the AttractionCard widget (the one returned by the build method) inside a ClipRRect widget, with the border radius set to 40px:
And once again, wrap the whole thing (this time, the ClipRRect widget) inside yet another Container widget. This widget will provide the required margin and shadow to the overall structure. To this Container widget, apply a margin all around of 20px, with a white background, replicating the border radius of the ClipRRect (border radius of 40px), and a box shadow with a blur radius of 20, color black with 10% opacity and offset zero, as follows:
Looking good! I think this is pretty much all for the AttractionCard. Let's take care of one last thing before we wrap up - I promise it will be worth it! Go to the next step to check it out.
With all AttractionCard widgets visible and inside the ListView, try scrolling the ListView items up, so as to show them cut off at the top a bit. Notice something? The parent Container widget inside the Scaffold's body is not clipping the contents, although it has a border radius applied to it.
Well, this is what the ClipRRect was design for!
At the ListPage widget, the Container widget set to the body of the Scaffold, wrap it inside a ClipRRect widget, but only clip the borders to the top left and top right corners, so as to match the rounded corners of the underlying Container and giving it the desired effect.
Take it for a final lap through DartPad by hitting Run, and how you should see the AttractionCard widget, in its full glory, fully fleshed out as per our design - fully custom-made, and property being cut off at the top when scrolling is performed. Kudos to you for making this far!
Congrats in making it this far! In this codelab, we accomplished the following:
In the next codelab, we'll flesh out the BottomBar widget and learn more about creating custom widgets. See you there!
In case you fell behind on this codelab, below is the whole code for this codelab in a way you can copy / paste directly into DartPad:
import 'package:flutter/material.dart';
const Color mainThemeColor = Color(0xFF272D8D);
final List<Attraction> attractionsList = [
Attraction(
imgPath:
'https://cf.bstatic.com/xdata/images/hotel/max1024x768/275162028.jpg?k=38b638c8ec9ec86624f9a598482e95fa634d49aa3f99da1838cf5adde1a14521&o=&hp=1',
name: 'Grand Bavaro Princess',
desc: 'All-Inclusive Resort',
location: 'Punta Cana, DR',
rating: 3,
price: 80.0),
Attraction(
imgPath:
'https://cf.bstatic.com/xdata/images/hotel/max1024x768/232161008.jpg?k=27808fe44ab95f6468e5433639bf117032c8271cebf5988bdcaa0a202b9a6d79&o=&hp=1',
name: 'Hyatt Ziva Cap Cana',
desc: 'All-Inclusive Resort',
price: 90.0,
rating: 4,
location: 'Punta Cana, DR'),
Attraction(
imgPath:
'https://cf.bstatic.com/xdata/images/hotel/max1024x768/256931299.jpg?k=57b5fb9732cd89f308def5386e221c46e52f48579345325714a310addf819274&o=&hp=1',
name: 'Impressive Punta Cana',
desc: 'All-Inclusive Resort',
price: 100.0,
rating: 5,
location: 'Punta Cana, DR'),
Attraction(
imgPath:
'https://cf.bstatic.com/xdata/images/hotel/max1024x768/283750757.jpg?k=4f3437bf1e1b077463c9900e4dd015633db1d96da38f034f4b70a4ba3ef76d82&o=&hp=1',
name: 'Villas Mar Azul Dreams',
desc: 'All-Inclusive Resort',
price: 100.0,
rating: 4,
location: 'Tallaboa, PR'),
];
void main() {
runApp(
MaterialApp(
debugShowCheckedModeBanner: false,
home: SplashPage()
)
);
}
class SplashPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
Future.delayed(const Duration(seconds: 2), () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ListPage())
);
});
return Stack(
children: [
Container(
color: mainThemeColor
),
Align(
alignment: Alignment.center,
child: Icon(
Icons.pool,
color: Colors.white,
size: 80
)
),
Align(
alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white.withOpacity(0.4)
)
)
)
],
);
}
}
class ListPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
iconTheme: IconThemeData(color: Colors.white),
elevation: 0,
title: Icon(Icons.pool, color: Colors.white),
actions: [
Container(
margin: EdgeInsets.only(right: 15),
child: Icon(
Icons.notifications,
color: Colors.white
)
)
]
),
backgroundColor: mainThemeColor,
body: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(50),
topRight: Radius.circular(50)
),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(50),
topRight: Radius.circular(50)
)
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ListView.builder(
itemCount: attractionsList.length,
itemBuilder: (context, index) {
Attraction attr = attractionsList[index];
return AttractionCard(attraction: attr);
})
)
]
)
)
)
);
}
}
class AttractionCard extends StatelessWidget {
Attraction? attraction;
AttractionCard({ this.attraction });
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(40),
boxShadow: [
BoxShadow(
blurRadius: 20,
offset: Offset.zero,
color: Colors.black.withOpacity(0.1)
)
]
),
child: ClipRRect(
borderRadius: BorderRadius.circular(40),
child: Container(
height: 300,
child: Stack(
children: [
Column(
children: [
Container(
height: 150,
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage(attraction!.imgPath!),
fit: BoxFit.cover
)
)
),
Container(
height: 150,
padding: EdgeInsets.all(20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(attraction!.name!,
style: TextStyle(
color: Colors.black,
fontSize:14,
fontWeight:FontWeight.bold
)
),
SizedBox(width: 5),
Row(
children: [
Icon(
Icons.pin_drop,
color: Colors.grey.withOpacity(0.7),
size: 12
),
SizedBox(width: 5),
Text(attraction!.location!,
style: TextStyle(
fontSize: 12,
color: Colors.grey.withOpacity(0.7),
fontWeight: FontWeight.bold
)
),
]
),
SizedBox(width: 5),
RatingWidget(rating: attraction!.rating!)
]
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('\$${attraction!.price!.toStringAsFixed(2)}',
style: TextStyle(
color: Colors.black,
fontSize:16,
fontWeight: FontWeight.bold
)
),
SizedBox(height: 5),
Text('Per Night',
style: TextStyle(
fontSize: 12,
color: Colors.grey.withOpacity(0.7),
fontWeight: FontWeight.bold
)
)
],
)
]
)
)
],
),
Align(
alignment: Alignment.centerRight,
child: Container(
child: Icon(
Icons.favorite,
color: Colors.white,
size: 15
),
margin: EdgeInsets.only(right: 10),
width: 40,
height: 40,
decoration: BoxDecoration(
color: mainThemeColor,
borderRadius: BorderRadius.circular(40),
boxShadow: [
BoxShadow(
blurRadius: 10,
color: mainThemeColor.withOpacity(0.5),
offset:Offset.zero
)
]
)
)
)
],
)
)
)
);
}
}
class RatingWidget extends StatelessWidget {
int? rating;
RatingWidget({ this.rating });
@override
Widget build(BuildContext context) {
return Row(
children: [
Row(
children: List.generate(5, (index) {
return Icon(
index < this.rating! ? Icons.star : Icons.star_border,
color: Colors.yellow
);
})
),
SizedBox(width: 5),
Text('${this.rating!}/5 Reviews',
style: TextStyle(
fontSize: 12,
color: Colors.grey.withOpacity(0.7)
)
)
],
);
}
}
class Attraction {
String? imgPath;
String? name;
String? desc;
double? price;
String? location;
int? rating;
Attraction({this.imgPath, this.name, this.desc, this.price, this.location, this.rating });
}