From design to Flutter #1 - Movie Details Page
Welcome to a new series called From Wireframes to Flutter! On this series, we’ll take inspiration from UI mockups, turn them into Flutter layouts and break down the components piece by piece.
To kick this thing off, we’ll start with a mockup called “Movie” by LEON.W on Dribbble. We’ll focus only on the movie details screen, hence the title of this article.
The sample project
The sample project is right here.
The models
I created a Movie
class for holding the information about the movie. Similarly, I also made an Actor
class for holding the actors’ name and avatar url. These are simply passed to our UI components so they know what to show.
For sample purposes, I created a single file that holds all the models needed about the movie, according to the original design.
Movie({
this.bannerUrl,
this.posterUrl,
this.title,
this.rating,
this.starRating,
this.categories,
this.storyline,
this.photoUrls,
this.actors,
});
final String bannerUrl;
final String posterUrl;
final String title;
final double rating;
final int starRating;
final List<String> categories;
final String storyline;
final List<String> photoUrls;
final List<Actor> actors;
}
class Actor {
Actor({
this.name,
this.avatarUrl,
});
final String name;
final String avatarUrl;
}
The data source
We aren’t going to hook our app with a live movie API. Working with APIs is a topic of its own and out of scope for this article. Instead, I just made an instance of the Movie class and filled it with the same information the mockup has.
final testMovie = Movie(
bannerUrl: 'images/banner.png',
posterUrl: 'images/poster.png',
title: 'The Secret Life of Pets',
rating: 8.0,
starRating: 4,
categories: ['Animation', 'Comedy'],
storyline: 'For their fifth fully-animated feature-film '
'collaboration, Illumination Entertainment and Universal '
'Pictures present The Secret Life of Pets, a comedy about '
'the lives our...',
photoUrls: [
'images/1.png',
'images/2.png',
'images/3.png',
'images/4.png',
],
actors: [
Actor(
name: 'Louis C.K.',
avatarUrl: 'images/louis.png',
),
Actor(
name: 'Eric Stonestreet',
avatarUrl: 'images/eric.png',
),
Actor(
name: 'Kevin Hart',
avatarUrl: 'images/kevin.png',
),
Actor(
name: 'Jenny Slate',
avatarUrl: 'images/jenny.png',
),
Actor(
name: 'Ellie Kemper',
avatarUrl: 'images/ellie.png',
),
],
);
This allows us to easily use the testMovie
as the source of data anywhere in our app. We’ll also get up to speed pretty quickly, since we don’t have to worry about networking here.
Our main.dart file
This is the main entry point for our app. There’s nothing fancy here, I just made the MovieDetailsPage to be the only page this app has.
import 'movie_api.dart';
import 'movie_details_page.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
accentColor: const Color(0xFFFF5959),
),
home: MovieDetailsPage(testMovie),
);
}
}
On a real world use case, we would probably want to display a list of movies as a first page. The MovieDetailsPage would be shown after the user clicks a movie in order to, you know, view the movie details. For this tutorial, we’re only focusing on the movie details page UI, so this is completely fine.
The MovieDetailsPage class
This is the main entry point for our movie details screen. MovieDetailsPage
takes a Movie
object as a constructor parameter, and passes that down for its subcomponents. This way we can hook the page easily to a backend later.
import 'actor_scroller.dart';
import 'models.dart';
import 'movie_detail_header.dart';
import 'photo_scroller.dart';
import 'storyline.dart';
class MovieDetailsPage extends StatelessWidget {
MovieDetailsPage(this.movie);
final Movie movie;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
child: Column(
children: [
MovieDetailHeader(movie),
Padding(
padding: const EdgeInsets.all(20.0),
child: Storyline(movie.storyline),
),
PhotoScroller(movie.photoUrls),
SizedBox(height: 20.0),
ActorScroller(movie.actors),
SizedBox(height: 50.0),
],
),
),
);
}
}
Although the page is contained within a Scaffold
, we don’t use an AppBar
here, since we want to display our movie background in all of its glory. The body is just a Column
which stacks our main widgets vertically. Finally, the whole thing is wrapped in a SingleChildScrollView
. For vertical margins, we use SizedBoxes
with heights that we want our spacing to be.
Let’s go through each component one by one and see what they’re made of.
MovieDetailHeader
The MovieDetailHeader is a simple Stack widget that hosts two Widgets as its children. A Stack
is a container that lays its children on top of each other. The first child is the arc background image, and second one a Row
widget, containing the poster and information about the movie.
Since the original mockup had the movie banner partially overlaid by the movie information, we use a little trick here: the ArcBannerImage
has a bottom padding of 140.0.
This simply stretches the available space on the bottom, so we can partially overlay the banner with the movie information. Without this trick, movie information would get on placed on top of the entire banner, resulting in a quite ugly effect:
We use the Positioned
widget, which is specific to only Stack
, to position our movie information at the bottom. The movie information Row
consists of two items: the movie poster and further information about the movie, which is a Column.
import 'arc_banner_image.dart';
import 'models.dart';
import 'poster.dart';
import 'rating_information.dart';
class MovieDetailHeader extends StatelessWidget {
MovieDetailHeader(this.movie);
final Movie movie;
List<Widget> _buildCategoryChips(TextTheme textTheme) {
return movie.categories.map((category) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
label: Text(category),
labelStyle: textTheme.caption,
backgroundColor: Colors.black12,
),
);
}).toList();
}
@override
Widget build(BuildContext context) {
var textTheme = Theme.of(context).textTheme;
var movieInformation = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
movie.title,
style: textTheme.title,
),
SizedBox(height: 8.0),
RatingInformation(movie),
SizedBox(height: 12.0),
Row(children: _buildCategoryChips(textTheme)),
],
);
return Stack(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 140.0),
child: ArcBannerImage(movie.bannerUrl),
),
Positioned(
bottom: 0.0,
left: 16.0,
right: 16.0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Poster(
movie.posterUrl,
height: 180.0,
),
SizedBox(width: 16.0),
Expanded(child: movieInformation),
],
),
),
],
);
}
}
The Column
contains three children: the title of the movie, the rating information and category chips. Yes, chips. Yummy!
One thing to note is that we use the Expanded
widget here. Without it, a longer movie title would get clipped. We also use a little padding on the left, so that the poster and movie information are not right next to each other.
ArcBannerImage
Having read the last weeks’ post about clipping images with bezier curves, this should be nothing new to us.
Here’s the end and control points for reference.
After fiddling around a little bit, this turned out to be even simpler than the last time.
It’s the same old ClipPath
and CustomClipper
dance we did the last time. Since the fine details of this have been already covered in the previous post, we won’t go through those again.
class ArcBannerImage extends StatelessWidget {
ArcBannerImage(this.imageUrl);
final String imageUrl;
@override
Widget build(BuildContext context) {
var screenWidth = MediaQuery.of(context).size.width;
return ClipPath(
clipper: ArcClipper(),
child: Image.asset(
imageUrl,
width: screenWidth,
height: 230.0,
fit: BoxFit.cover,
),
);
}
}
class ArcClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
var path = Path();
path.lineTo(0.0, size.height - 30);
var firstControlPoint = Offset(size.width / 4, size.height);
var firstPoint = Offset(size.width / 2, size.height);
path.quadraticBezierTo(firstControlPoint.dx, firstControlPoint.dy,
firstPoint.dx, firstPoint.dy);
var secondControlPoint = Offset(size.width - (size.width / 4), size.height);
var secondPoint = Offset(size.width, size.height - 30);
path.quadraticBezierTo(secondControlPoint.dx, secondControlPoint.dy,
secondPoint.dx, secondPoint.dy);
path.lineTo(size.width, 0.0);
path.close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
The only thing to remember here is that we get the image source from outside the class in the imageUrl
constructor parameter. This makes it easy to control what image should be shown, from the outside.
Also we’re setting the image width to equal the screen width. Combined with BoxFit.cover
, this makes our image look decent on different screen sizes.
Poster
I decided to make the poster to be in a separate class. Why? Because the poster has to always have a specific aspect ratio. If we give it a height, it should automatically resolve its width.
To show an image in our layout, we must know the source of the image we want to show. You guessed it, the Poster class needs to recieve a posterUrl in its constructor parameter. Our posters should also have rounded corners and a nice little drop shadow.
class Poster extends StatelessWidget {
static const POSTER_RATIO = 0.7;
Poster(
this.posterUrl, {
this.height = 100.0,
});
final String posterUrl;
final double height;
@override
Widget build(BuildContext context) {
var width = POSTER_RATIO * height;
return Material(
borderRadius: BorderRadius.circular(4.0),
elevation: 2.0,
child: Image.asset(
posterUrl,
fit: BoxFit.cover,
width: width,
height: height,
),
);
}
}
To achieve the rounded corners and the drop shadow effect in the simplest way possible, we use the Material widget. The drop shadow intensity is controlled by the elevation
property.
We want the poster to fill the entire available area it’s given, but without distorting the image proportions. BoxFit.cover
does exactly that. The POSTER_RATIO
is 0.7 just because it looks to be something similar according to the mockup.
RatingInformation
The rating information widget is a Row, which is simply a container that lays its children horizontally. The row has two children, which both are Columns that are used for laying children vertically. The first child of each column is the rating information part, and the second child is the caption text.
The first part of the rating information widget is just a Text
, displaying a current rating as a number. We also have an actual star rating bar that displays the star rating for the movie.
I actually don’t know why the original mockup has two rating elements, but I was way too far to go back and choose something else.
import 'models.dart';
class RatingInformation extends StatelessWidget {
RatingInformation(this.movie);
final Movie movie;
Widget _buildRatingBar(ThemeData theme) {
var stars = <Widget>[];
for (var i = 1; i <= 5; i++) {
var color = i <= movie.starRating ? theme.accentColor : Colors.black12;
var star = Icon(
Icons.star,
color: color,
);
stars.add(star);
}
return Row(children: stars);
}
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
var textTheme = theme.textTheme;
var ratingCaptionStyle = textTheme.caption.copyWith(color: Colors.black45);
var numericRating = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
movie.rating.toString(),
style: textTheme.title.copyWith(
fontWeight: FontWeight.w400,
color: theme.accentColor,
),
),
SizedBox(height: 4.0),
Text(
'Ratings',
style: ratingCaptionStyle,
),
],
);
var starRating = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildRatingBar(theme),
Padding(
padding: const EdgeInsets.only(top: 4.0, left: 4.0),
child: Text(
'Grade now',
style: ratingCaptionStyle,
),
),
],
);
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
numericRating,
SizedBox(width: 16.0),
starRating,
],
);
}
}
The star rating is an integer between 1-5, and we just loop from 1 to 5, adding star icons to our list of star widgets as we go. If the current position in the loop is less than or equal the star rating, we colorize the star icon. Otherwise, we’ll just set it to a fairly transparent black color.
Storyline
The storyline widget is probably the most simple one. It’s a Column
widget, containing the title and storyline Text widgets. There’s also the “more” button, aligned at the bottom right corner below the storyline text.
I know, I know, the “more” button is in a different place than on the original mockup! It’s this way because of purely practical reasons.
Determining where the text gets clipped off and placing the “more” button right after it is hard. It’s most likely doable, but would cost us more time for no extra value in the end.
class Storyline extends StatelessWidget {
Storyline(this.storyline);
final String storyline;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
var textTheme = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Story line',
style: textTheme.subhead.copyWith(fontSize: 18.0),
),
SizedBox(height: 8.0),
Text(
storyline,
style: textTheme.body1.copyWith(
color: Colors.black45,
fontSize: 16.0,
),
),
// No expand-collapse in this tutorial, we just slap the "more"
// button below the text like in the mockup.
Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'more',
style: textTheme.body1
.copyWith(fontSize: 16.0, color: theme.accentColor),
),
Icon(
Icons.keyboard_arrow_down,
size: 18.0,
color: theme.accentColor,
),
],
),
],
);
}
}
One thing to note is that we don’t implement expand-collapse functionality in this article. We’re just duplicating the UI and layout. If you’d like for a quick article on explaining how to integrate nice looking expand-collapse functionality, go ahead and leave a comment!
PhotoScroller
This is the Widget responsible for displaying the screenshots of the movie.
It uses a horizontal ListView with the builder approach. The ListView.builder
is used for displaying long lists of data with good performance. It’s especially useful if you load something from a server and don’t know how many items you may get.
The builder is called every time a new item is going to be showed on screen. The items are built on-demand instead of keeping every needed Widget in memory all times. This is more or less an equivalent of RecyclerView
on Android and UITableView
on iOS.
class PhotoScroller extends StatelessWidget {
PhotoScroller(this.photoUrls);
final List<String> photoUrls;
Widget _buildPhoto(BuildContext context, int index) {
var photo = photoUrls[index];
return Padding(
padding: const EdgeInsets.only(right: 16.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(4.0),
child: Image.asset(
photo,
width: 160.0,
height: 120.0,
fit: BoxFit.cover,
),
),
);
}
@override
Widget build(BuildContext context) {
var textTheme = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Text(
'Photos',
style: textTheme.subhead.copyWith(fontSize: 18.0),
),
),
SizedBox.fromSize(
size: const Size.fromHeight(100.0),
child: ListView.builder(
itemCount: photoUrls.length,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(top: 8.0, left: 20.0),
itemBuilder: _buildPhoto,
),
),
],
);
}
}
For our list items, we simply return images roughly the same size as in the original mockup. The images are wrapped in a ClipRRect
(=ClipRoundRect?) widget which makes it easy to add rounded corners to any Widget. Finally, we wrap the images in a Padding
widget to have some space to the right, so that the widgets are not right next to each other.
One thing to note is that we also wrap the ListView
in a SizedBox
widget with a predefined height. Otherwise our ListView
will take an infinite height which results in some UI constraint errors, since it’s already wrapped in a scroll view.
ActorScroller
This is a lot similar to PhotoScroller: we have a horizontal list of items that we build on demand.
Basically the only different thing here is that the list items are now CircleAvatars
, with the actors names below them. Otherwise it’s all the same here, including defining the height for the ListView
in the SizedBox
widget.
import 'models.dart';
class ActorScroller extends StatelessWidget {
ActorScroller(this.actors);
final List<Actor> actors;
Widget _buildActor(BuildContext ctx, int index) {
var actor = actors[index];
return Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Column(
children: [
CircleAvatar(
backgroundImage: AssetImage(actor.avatarUrl),
radius: 40.0,
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(actor.name),
),
],
),
);
}
@override
Widget build(BuildContext context) {
var textTheme = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Text(
'Actors',
style: textTheme.subtitle1.copyWith(fontSize: 18.0),
),
),
SizedBox.fromSize(
size: const Size.fromHeight(120.0),
child: ListView.builder(
itemCount: actors.length,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(top: 12.0, left: 20.0),
itemBuilder: _buildActor,
),
),
],
);
}
}
The CircleAvatar
widget comes in really handy. Even if we don’t have an image to show, we can show the actors initials in it using the child
property.
Wrapping it up
One of the few downsides of Flutter is that there’s no layout DSL. This can sometimes result in ugly, hard to read UI code.
Thankfully, we can combat this by extracting our UI components to separate classes, methods and variables when appropriate. I’ve started extracting code to at least variables if not methods if I see too much indentation for my taste.
On the other hand, I’m fairly confident that there’s no UI that can’t be built with Flutter. Only Sky’s the limit.
16 comments
Great write up! Thanks for sharing :)
Love it!
I hope there will be more :)
There definitely will be! Glad you liked it.
Nice. Thanks for this. Still holding out for a WebView and a MapView, but otherwise, Flutter looks great!
Thanks for the article.
I love Dart. All the JS frameworks for web or mobile development make me depressed. I felt like not doing any front-end work and hibernate for a while (do backend for paycheck). Typescript, React and GWT (other sugarcoated front-end framework) convert to JS. So why not use nice language like Dart to make webapps (convert to JS) and mobile (cross platform)
Dart makes me happy doing front end work. Never developed or coded in dart but 15 years of development experience help me creating basic mobile tip calculator app in 1 hour. Hope google doesn’t make same mistake with dart and promote it to enterprise client. Flutter is very promising and fun to work platform (feel like doing some mobile apps even if I never developed one before)
Glad you liked the article!
I kinda feel neutral towards Dart. Sure, it’s a nice and modern language, but for example Kotlin is a lot more to my liking. It has the least amount of boilerplate out of all the languages I’ve tried so far.
That being said, Flutter is seriously amazing and a breath of fresh air for mobile development. If I had to choose Kotlin + native mobile development or Dart & Flutter, it would be the latter all the way. And that’s coming from an Android developer of several years.
That’s actually funny that you mentioned using Dart for mobile and web at the same time. There was this thing on GitHub I saw that would more or less enable sharing Flutter code with a frontend client which seemed really interesting!
You can share same Dart code with web apps and flutter. The easiest way to do is to factor the shared code into a package that doesn’t depend on dart:html or dart:ui.
There are teams at Google which follow the path. One such talk at dart conference last year:
Yep, you can share the business logic easily.
The thing I was talking about was this thing called “Flur”, which is experimental, but compiles even Flutter-dependent code to web.
Hi Mr.Krankka, thanks a dozen for this amazing piece. I’ve gone through all your flutter tuts and i’m still left hungry for more. Can you please continue putting up flutter turtorials? And also, i will love to know how to do the expand and collapse feature, you mentioned on this tutorial.
Thanks Again for the great work
Glad you like it! What kind of tutorials you feel like are missing?
Thanks for your Responds.
For example the expand collapse feature, then the first page showing list of movies (https://www.uplabs.com/post…). I think a tutorial on this would give us more insight on developing cool views with Flutter.
Hi Iiro,
Yet another great tutorial, thank you! I have only recently discovered Flutter and shortly after discovered your blog. Your articles provide a lot of insights into the power of Flutter, so thank you for that and keep up the awesome work!
Also coming from native development I am interested in things like:
1. Best practice for structuring real projects
2. Best practice for separating concerns, e.g. creating a data “module”
3. More best practice in general
4. Making API calls
In regards to why the UI has 2 ratings element, I believe one is to show the average user rating and the other is to allow the user to add their rating. The issue is currently the stars are filled but the label is still saying “Grade Now”, which is conflicting. If user hasn’t rated the movie it should not be filled and if they have the label should be something like “Your Grade”.
Also was the background intentionally not white because it clashes with the background of this blog?
Great tutorial! I’d love to see an article about the expand/collapse feature!
Thanks for sharing? Is there any article to separate the front end flutter UI and the back end such as the database. I see your are using some mock up model.
Nice post. I learn something totally new and challenging on sites I stumbleupon everyday. It will always be interesting to read through articles from other authors and use something from their sites.
Greate work sir
Comments closed
I'm doing some maintenance work. Sorry about that!