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 actual end result of this article: a nice looking movie details page.

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.

models.dart
  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.

movie_api.dart

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.

main.dart
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.

movie_details_page.dart
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.

A visual breakdown of what parts our movie detail header consists of."

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:

That's ugly: the header without bottom padding on the ArcBannerImage.

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.

movie_detail_header.dart
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.headline6,
        ),
        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.

End & control points for our bezier curves.

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.

arc_banner_image.dart

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.

poster.dart

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 rating information widget.

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.

rating_information.dart

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.headline6!.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.

The storyline widget.

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.

storyline.dart

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.subtitle1!.copyWith(fontSize: 18.0),
        ),
        SizedBox(height: 8.0),
        Text(
          storyline!,
          style: textTheme.bodyText2!.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.bodyText2!
                  .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.

The photo scroller widget.

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.

photo_scroller.dart

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.subtitle1!.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.

The actor scroller widget.

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.

actor_scroller.dart
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.