5. Homescreen Carousel

5. Homescreen Carousel

Hello, Welcome back. Glad that you’re here.

We are building a Movie App with the best coding practices and tools. In previous tutorials, Datasources, Repositories & UseCase and Dependency Injection Injection.

In this tutorial, we'll see create the top part in HomeScreen that contains Animated Carousel. Together with this, we'll also do the basic setup for UI.

UI Setup

Assets

I have gathered some assets and put them in GitHub already, you can check out the master branch or _5_homescreencarousel branch. You can take them from there. Now, open pubspec.yaml and update the assets there as well.

assets:
    - assets/svgs/
    - assets/pngs/

Colors

In the presentation/themes folder, create a new file app_color.dart. This file will have all the colors required in the application, except for those which are already defined in the Flutter framework. MovieApp only uses 3 colors throughout the application:

//1
class AppColor {
  //2
  const AppColor._();
  //3
  static const Color vulcan = Color(0xFF141221);
  static const Color royalBlue = Color(0xFF604FEF);
  static const Color violet = Color(0xFFA74DBC);
}
  1. Create a class AppColor.
  2. Add a private constructor, since it is not required to instantiate the class.
  3. Declare the colors Vulcan, royalBlue, violet.

Naming colors - You might often find naming colors in the app tedious. Use this website. Some colors would be hard to spell out, so you can name them anything you want, as I have done with violet in this app.

We're done with the colors.

Initialize App

Open main.dart and clear out the main():

//1
void main() {
  //2
  WidgetsFlutterBinding.ensureInitialized();
  //3
  unawaited(
      SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]));
  //4
  unawaited(getIt.init());
  //5
  runApp(MovieApp());
}
  1. You don't need async now, as we're using unawaited. In the future, we'll need it when we do Hive initialization.
  2. As per official Flutter documentation, this is the glue that binds the framework to the Flutter engine.
  3. As we're only supporting portrait till now, you should force it to have portraitUp orientation. If in the future, you support other orientations, you can remove this snippet.
  4. Initialize GetIt to provide us with dependencies.
  5. Instead of having the app in this file itself, make this class small by moving out the MaterialApp widget in movie_app.dart (more on that soon).

Custom ScreenUtil

We all know, even though it is claimed that Flutter can create the UI for any screen size. But in certain situations when you give fixed width and height to a widget, they don't seem to be proportional to the screen for obvious reasons.

I have been using flutter_screenutil in my recent work and this plugin helps in creating pixel-perfect UI in Flutter. With recent advancements in mobile form factors with some of them having notches at the top or bottom of the screen, the current calculation of scaleHeight should also consider the notches.

Go to Flutter ScreenUtil GitHub Repository, and copy the code.

Create a screenutil sub-folder in common folder and paste the code in a new file screenutil.dart

Change two things in the file:

//1
static const int defaultWidth = 414;
static const int defaultHeight = 896;

//2
double get scaleHeight =>
      (_screenHeight - _statusBarHeight - _bottomBarHeight) / uiHeightPx;
  1. Here, defaultWidth and defaultHeight are the width and height of the designs that the designer has used. They are not your mobile screen width and height. If you don't want to specify the defaultWidth and defaultHeight, you can do so by invoking ScreenUtil.init(width: 414,height: 896,); before returning MaterialApp.
  2. The new scaleHeight factor now considers top and bottom notches if present in some phones.

If you've seen my previous videos, I have shown you how to use ScreenUtil in the dimensions. You generally call ScreenUtil().setWidth(100), ScreenUtil().setHeight(100) or ScreenUtil().setSp(100). Here 100 is the dimension that you scale according to the screen dimensions. To reduce some boilerplate, let's create extensions for this.

In the common/extensions folder, create a new file size_extensions.dart

Create an extension on num:

extension SizeExtension on num {
  num get w => ScreenUtil().setWidth(this);

  num get h => ScreenUtil().setHeight(this);

  num get sp => ScreenUtil().setSp(this);
}

This is straightforward. Now, you can invoke the methods like 100.w, 100.h or 100.sp whenever required.

Sizes

As you've seen we directly used 100. This is straightforward but imagine 100 is used 10-20 times in the app, Will you every time use 100. The answer is NO. This will consume more memory. Instead, declare all the dimensions in one file.

In the common/constants folder, create a file size_constants.dart

Create a class Sizes and declare dimensions as static const double:

class Sizes {
  Sizes._();

  static const double dimen_0 = 0;
  static const double dimen_1 = 1;
  static const double dimen_2 = 2;
  static const double dimen_4 = 4;
  static const double dimen_6 = 6;
  static const double dimen_8 = 8;
  static const double dimen_10 = 10;
  static const double dimen_12 = 12;
  static const double dimen_14 = 14;
  static const double dimen_16 = 16;
  static const double dimen_18 = 18;
  static const double dimen_20 = 20;
  static const double dimen_24 = 24;
  static const double dimen_32 = 32;
  static const double dimen_40 = 40;
  static const double dimen_48 = 48;
  static const double dimen_80 = 80;
  static const double dimen_100 = 100;
  static const double dimen_110 = 110;
  static const double dimen_140 = 140;
  static const double dimen_150 = 150;
  static const double dimen_200 = 200;
  static const double dimen_230 = 230;
}

This is very similar to how we created AppColor. Now, throughout the application, you'll use Sizes.dimen_100.

Text Theming

In this tutorial, I'll show only one TextStyle, but we'll create the file where all the text styles will go.

We'll use Google Fonts, so let's add google_fonts dependency. Open pubspec.yaml:

google_fonts: ^1.1.0

In the presentation/themes folder, create a file theme_text.dart

Create a class in a similar fashion as Sizes and AppColor:

class ThemeText {
  const ThemeText._();

  //1
  static TextTheme get _poppinsTextTheme => GoogleFonts.poppinsTextTheme();
  //2
  static TextStyle get _whiteHeadline6 => _poppinsTextTheme.headline6.copyWith(
        fontSize: Sizes.dimen_20.sp,
        color: Colors.white,
      );
  //3
  static getTextTheme() => TextTheme(
        headline6: _whiteHeadline6,
      );
}
  1. We're using Poppins Font.
  2. Create a white headline6 (Refer below guidelines for font sizes from Material Design), by copying the Poppins' headline6. We add .sp so that the text sizes are also according to the screen width.
  3. Create a public static method that returns the TextTheme. This will be used by MaterialApp, rest of all methods can be private.
/// NAME         SIZE  WEIGHT  SPACING
/// headline1    96.0  light   -1.5
/// headline2    60.0  light   -0.5
/// headline3    48.0  regular  0.0
/// headline4    34.0  regular  0.25
/// headline5    24.0  regular  0.0
/// headline6    20.0  medium   0.15
/// subtitle1    16.0  regular  0.15
/// subtitle2    14.0  medium   0.1
/// body1        16.0  regular  0.5   (bodyText1)
/// body2        14.0  regular  0.25  (bodyText2)
/// button       14.0  medium   1.25
/// caption      12.0  regular  0.4
/// overline     10.0  regular  1.5

MaterialApp

As you can recall, before creating screen_util.dart, I updated main.dart with MovieApp.

In the presentation folder, create a new file movie_app.dart

Create MovieApp as a stateless widget:

class MovieApp extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    //1
    ScreenUtil.init();
    //2
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Movie App',
      theme: ThemeData(
        //3
        primaryColor: AppColor.vulcan,
        scaffoldBackgroundColor: AppColor.vulcan,
        visualDensity: VisualDensity.adaptivePlatformDensity,
        textTheme: ThemeText.getTextTheme(),
        appBarTheme: const AppBarTheme(elevation: 0),
      ),
      //4
      home: HomeScreen(),
    );
  }
}
  1. Initialize ScreenUtil so that we can use it while defining
  2. Use the MaterialApp widget with debugBanner as false or true.
  3. Define ThemeData of the app. Make Vulcan as primary as well as scaffoldBackground color. All our screens have Vulcan as their background color. Also, give the text theme.
  4. Our first screen - HomeScreen (we're yet to design this).

HomeScreen (Top Part)

In this tutorial, I'll show the only top part of HomeScreen as this also contains a basic starting setup.

Before we move to UI, let's add the Bloc as well which will manage the state of our Carousel.

In the presentation/blocs folder, with the help of bloc extension, create a new bloc with movie_carousel name. Rename the auto-generated bloc folder with movie_carousel. You can see bloc, state and event file auto-generated.

In movie_carousel_event.dart add CarouselLoadEvent:

//1
class CarouselLoadEvent extends MovieCarouselEvent {
  //2
  final int defaultIndex;
  //3
  const CarouselLoadEvent({this.defaultIndex = 0}): 
    assert(defaultIndex >= 0, 'defaultIndex cannot be less than 0');
  //4
  @override
  List<Object> get props => [defaultIndex];
}
  1. Extend the event with abstract class because the bloc's definition accepts the type of MovieCarouselEvent. This event will be dispatched when the user comes to the screen.
  2. defaultIndex will give us the flexibility to decide which movie will be in the center of our carousel at the start.
  3. A const constructor with defaultIndex as 0, if not passed.
  4. props as explained in previous tutorials is used for comparison between 2 objects of the same type.

In movie_carousel_state.dart, create 3 states:

//1
class MovieCarouselInitial extends MovieCarouselState {}
//2
class MovieCarouselError extends MovieCarouselState {}
//3
class MovieCarouselLoaded extends MovieCarouselState {
  final List<MovieEntity> movies;
  final int defaultIndex;

  const MovieCarouselLoaded({
    this.movies,
    this.defaultIndex = 0,
  }): assert(defaultIndex >= 0, 'defaultIndex cannot be less than 0');

  @override
  List<Object> get props => [movies, defaultIndex];
}
  1. MovieCarouselInitial to be emitted as the first state when the bloc initializes.
  2. MovieCarouselError to be emitted if there is an error thrown from API. I'll not observe this state in this tutorial.
  3. MovieCarouselLoaded to be emitted with a list of trending movies and default index, which is passed from CarouselLoadEvent earlier.

In movie_carousel_bloc.dart, declare GetTrending usecase:

class MovieCarouselBloc extends Bloc<MovieCarouselEvent, MovieCarouselState> {
  //1
  final GetTrending getTrending;
  //2
  MovieCarouselBloc({
    @required this.getTrending,
  }) : super(MovieCarouselInitial());

  @override
  Stream<MovieCarouselState> mapEventToState(
    MovieCarouselEvent event,
  ) async* {
    //TODO: handle event to state
  }
}
  1. GetTrending will be the final variable.
  2. Clearly, MovieCarouselBloc is dependent on GetTrending, if you remember from the previous tutorial about Dependency Injection.

Replace the TODO with the call to GetTrending and handle the response:

//1
if (event is CarouselLoadEvent) {
  //2
  final moviesEither = await getTrending(NoParams());
  //3
  yield moviesEither.fold(
    //4
    (l) => MovieCarouselError(),
    //5
    (movies) {
      return MovieCarouselLoaded(
        movies: movies,
        defaultIndex: event.defaultIndex,
      );
    },
  );
}
  1. Handle if the event dispatched is CarouselLoadEvent
  2. Call the getTrending usecase with NoParams()
  3. Use the fold operator to handle the response
  4. When an error(left), yield error state (Future-proof), not handling this state in this tutorial.
  5. When success(right), yield success state with movies and default index.

Before we move to Widgets using this, let's add the dependency in get_it.dart:

getItInstance.registerFactory(
  () => MovieCarouselBloc(
    getTrending: getItInstance(),
  ),
);

This time we'll declare the factory because we want a new instance of the bloc whenever we need the carousel bloc. Since this is a home screen, the first screen, you can also declare this bloc as a singleton, totally your choice.

We're ready with Bloc, Colors, Fonts, Dimensions. Let's jump to UI creation now.

Widget Segregation

I am listing down all the widgets that we'll create from the bottom-up approach. Preferably sequence is a smaller widget to the bigger widget, but in some cases, it is the order of creation as well.

  1. Home Screen
  2. Logo Widget
  3. Movie App Bar Widget
  4. Movie Carousel Widget
  5. Movie Card Widget
  6. Movie Page View Widget
  7. Animated Movie Card Widget
  8. Movie Backdrop Widget
  9. Movie Data Widget
  10. Separator Widget

HomeScreen

We're starting our first journey, create a home folder in the journeys folder.

In the journeys/home, create a new file home_screen.dart

Create a Stateful widget - HomeScreen

class HomeScreen extends StatefulWidget {
  @override
  _HomeScreenState createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  MovieCarouselBloc movieCarouselBloc;

  @override
  void initState() {
    super.initState();
    //1
    movieCarouselBloc = getItInstance<MovieCarouselBloc>();
    //2
    movieCarouselBloc.add(CarouselLoadEvent());
  }

  @override
  void dispose() {
    super.dispose();
    //3
    movieCarouselBloc?.close();
  }

  @override
  Widget build(BuildContext context) {
    // TODO: Your UI goes here
  }
}
  1. Initialize the MovieCarouselBloc from GetIt.
  2. When the home screen initializes, dispatch the only event for MovieCarouselBloc This will make an API call and yield the MovieCarouselLoaded or MovieCarouselError state.
  3. In dispose(), don't forget to close the bloc.

The home screen has 2 sections - top and bottom. To make these sections proportional for any mobile size, we'll use FractionallySizedBox. The top section is 60% of the screen and the bottom section is 40%. Let's create Stack with 2 FractionallySizedBox.

@override
Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        //1
        fit: StackFit.expand,
        children: <Widget>[
          //2
          FractionallySizedBox(
            //3
            alignment: Alignment.topCenter,
            //4
            heightFactor: 0.6,
            // TODO: Add MovieCarouselWidget here in child
            child: Placeholder(color: Colors.grey),
          ),
          FractionallySizedBox(
            //5
            alignment: Alignment.bottomCenter,
            //6
            heightFactor: 0.4,
            //7
            child: Placeholder(color: Colors.white),
          ),
        ],
      ),
    );
}
  1. When you use FractionallySizedBox, you should use StackFit.expand, because this allows the stack to take the available space.
  2. FractionallySizedBox uses fractions to decide on the proportion of screen that it will take.
  3. The top part should have topCenter as its alignment.
  4. The top section is 60% of the screen, hence it should be aligned top with 60% of the height of the available space, which in this case is the complete screen. Once, we add the MovieCarouselWidget, it will go in this part, replacing the Placeholder.
  5. The bottom FractionallySizedBox, obviously will have bottomCenter as its alignment.
  6. The heightFactor of this remaining part of the screen will be 0.4.
  7. MovieTabbedWidget will replace the Placeholder widget, in the next tutorial.

If you run the app with this code, you'll see the screen perfectly divided into 2 parts. Check this screen in different screen sizes.

Logo Widget

As I said, we'll develop widgets from bottom to top. So, before moving to the actual MovieCarouselWidget, let's create a logo widget that will go in MovieAppBar and MovieAppBar goes in MovieCarouselWidget.

In the presentation/widgets folder, create a new file logo.dart:

class Logo extends StatelessWidget {
  //1
  final double height;
  //2
  const Logo({
    Key key,
    @required this.height,
  })  : assert(height != null, 'height must not be null'),
        assert(height > 0, 'height should be greater than 0'),
        super(key: key);

  @override
  Widget build(BuildContext context) {
    //3
    return Image.asset(
      'assets/pngs/logo.png',
      color: Colors.white,
      height: height.h,
    );
  }
}
  1. Logo is a stateless widget with a dynamic height that will be provided by the calling widget. This is important here because the same Logo widget will be used in the NavigationDrawer when we implement that.
  2. Constructor with height as required field and add some assertions, that make this widget fail-safe. With these two assertions, this widget unknowingly can't be called with height as null or <= 0.
  3. Just use the logo image from the assets/pngs folder. Notice the usage of .h.

MovieAppBar

Even though this app has only one instance of the custom AppBar, it is always good to create a separate widget for maintainability and scalability. We'll also use SVG images now, so add flutter_svg dependency:

flutter_svg: ^0.18.0

In the presentation/widgets folder, create a new file movie_app_bar.dart:

class MovieAppBar extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //1
    return Padding(
      padding: EdgeInsets.only(
        top: ScreenUtil.statusBarHeight + Sizes.dimen_4.h,
        left: Sizes.dimen_16.w,
        right: Sizes.dimen_16.w,
      ),
      //2
      child: Row(
        children: <Widget>[
          //3
          IconButton(
            onPressed: () {},
            icon: SvgPicture.asset('assets/svgs/menu.svg', height: Sizes.dimen_12.h),
          ),
          //4
          Expanded(
            child: const Logo(height: Sizes.dimen_14),
          ),
          //3
          IconButton(
            onPressed: () {},
            icon: Icon(Icons.search, color: Colors.white, size: Sizes.dimen_12.h),
          ),
        ],
      ),
    );
  }
}
  1. Because we're creating our app bar, it is necessary to have padding from left, right, and top. Notice, we're considering the notch height in top padding to make it work for phones with the notch at the top. It is useless to mention the use of .w when considering the horizontal spacing and .h when considering the vertical spacing.
  2. Use Row to layout the elements in horizontal.
  3. In Row, at start and end add the 2 IconButtons. One being SvgPicture and the other being taken from the Flutter framework itself.
  4. For The remaining space in between these 2 images, use the Logo widget.

MovieCarouselWidget

This widget requires the list of movies, and the default movie index that will appear in the center of the carousel. Let's use the bloc to get the fetched movies.

Update the HomeScreen:

@override
@override
Widget build(BuildContext context) {
  //1
  return BlocProvider(
    //2
    create: (_) => movieCarouselBloc,
    child: Scaffold(
      //3
      body: BlocBuilder<MovieCarouselBloc, MovieCarouselState>(
        bloc: movieCarouselBloc,
        builder: (context, state) {
          //4
          if (state is MovieCarouselLoaded) {
            return Stack(
              fit: StackFit.expand,
              children: <Widget>[
                FractionallySizedBox(
                  alignment: Alignment.topCenter,
                  heightFactor: 0.6,
                  child: MovieCarouselWidget(
                    movies: state.movies,
                    defaultIndex: state.defaultIndex,
                  ),
                ),
                FractionallySizedBox(
                  alignment: Alignment.bottomCenter,
                  heightFactor: 0.4,
                  child: Placeholder(color: Colors.white),
                ),
              ],
            );
          }
          //5
          return const SizedBox.shrink();
        },
      ),
    ),
  );
}
  1. Use BlocProvider to provide the MovieCarouselBloc instance down the tree.
  2. You need not create the bloc here as it is already done in initState().
  3. Use BlocBuilder to read the current state of MovieCarouselBloc. The builder takes in context and state.
  4. When loading trending movies are a success, we show the previously used Stack having two sections - Top and Bottom. Give the MovieCarouselWidget with instead of the first PlaceHolder.
  5. When loading trending movies is an error, we show an empty-sized box as of now. Later, in the coming tutorials, I'll show you how to handle UI when errors.

If you remember the MovieCarouselLoaded state contains movies and a default index, so we'll create MovieCarouselWidget that will be used in the top section of the Stack.

In the presentation/journeys/home/movie_carousel, create a new file movie_carousel_widget.dart:

class MovieCarouselWidget extends StatelessWidget {
  //1
  final List<MovieEntity> movies;
  final int defaultIndex;

  //2
  const MovieCarouselWidget({
      Key key,
      @required this.movies,
      @required this.defaultIndex,
    })  : assert(defaultIndex >= 0, 'defaultIndex cannot be less than 0'),
          super(key: key);

  @override
  Widget build(BuildContext context) {
    //3
    return Column(
      children: [
        MovieAppBar(),
        //4
        MoviePageView(
          movies: movies,
          initialPage: defaultIndex,
        ),
      ],
    );
  }
}
  1. MovieCarouselWidget is a stateless widget, that works on the list of movies and the defaultIndex.
  2. Create a constructor with both the fields as required and add the assertion, that we've been adding everywhere for defaultIndex. Assertions are a very good way for reducing the number of errors when working individually or as part of a team.
  3. A column with just 2 elements. The first being the MovieAppBar that we created before.
  4. Next in Column is MoviePageView, below MovieAppBar that we'll create now. This widget also takes in the list of movies and the defaultIndex.

MovieCardWidget

In general, MoviePageView is a PageView, that takes in multiple children. Each child is a MovieCardWidget. Let's create that first.

We're about to load an image from the internet, let's add a dependency:

cached_network_image: ^2.2.0+1

In the presentation/journeys/home/movie_carousel, create a new file movie_card_widget.dart:

class MovieCardWidget extends StatelessWidget {
  //1
  final int movieId;
  //2
  final String posterPath;

  const MovieCardWidget({
    Key key,
    @required this.movieId,
    @required this.posterPath,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //6
    return Material(
      elevation: 32,
      borderRadius: BorderRadius.circular(Sizes.dimen_16.w),
      //5
      child: GestureDetector(
        onTap: () {},
        //4
        child: ClipRRect(
          borderRadius: BorderRadius.circular(Sizes.dimen_16.w),
          //3
          child: CachedNetworkImage(
            imageUrl: '${ApiConstants.BASE_IMAGE_URL}$posterPath',
            fit: BoxFit.cover,
          ),
        ),
      ),
    );
  }
}
  1. You'll need movieId in the future when we tap on this card to move to the movie detail screen.
  2. The posterPath is required to load the image. This will be taken from MovieEntity and will be in this format kqjL17yufvn9OVLyXYpvtyrFfak.jpg.
  3. Use CachedNetworkImage with the imageUrl prepended with BASE_IMAGE_URL. In DataSources, I have explained the use of BASE_IMAGE_URL.
  4. Use ClipRRect to clip the image, with a borderRadius. This will add the curves on all the vertices of the images.
  5. Use GestureDetector to enable tappable events on the card.
  6. Use Material to give elevation to the card.

MoviePageView

Let's create the MoviePageView now:

In the presentation/journeys/home/movie_carousel, create a new file movie_page_view.dart:

//1
class MoviePageView extends StatefulWidget {
  final List<MovieEntity> movies;
  final int initialPage;

  const MoviePageView({
    Key key,
    @required this.movies,
    @required this.initialPage,
  })  : assert(initialPage >= 0, 'initialPage cannot be less than 0'),
        super(key: key);

  @override
  _MoviePageViewState createState() => _MoviePageViewState();
}

class _MoviePageViewState extends State<MoviePageView> {
  //2
  PageController _pageController;

  @override
  void initState() {
    super.initState();
    //3
    _pageController = PageController(
      initialPage: widget.initialPage,
      keepPage: false,
      viewportFraction: 0.7,
    );
  }

  @override
  void dispose() {
    //4
    _pageController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    //TODO: PageView.Builder
  }
}
  1. Create a stateful widget with a list of movies and initialPage. The initialPage is the same as defaultIndex, so apply the same assertion to this as well.
  2. In the State class of MoviePageView, declare a PageController.
  3. In initState(), instantiate _pageController with viewportFraction as 0.7. viewportFraction decides how much screen share each item of PageView will take.

In the same file, create the UI.

@override
Widget build(BuildContext context) {
  //6
  return Container(
    //7
    margin: EdgeInsets.symmetric(vertical: Sizes.dimen_10.h),
    //8
    height: ScreenUtil.screenHeight * 0.35,
    //1
    child: PageView.builder(
      controller: _pageController,
      itemBuilder: (context, index) {
        //2
        final MovieEntity movie = widget.movies[index];
        return MovieCardWidget(
          movieId: movie.id,
          posterPath: movie.posterPath,
        );
      },
      //3
      pageSnapping: true,
      //4
      itemCount: widget.movies?.length ?? 0,
      //5
      onPageChanged: (index) {},
    ),
  );
}
  1. Use PageView.Builder. Builder is efficient when you don't know how many children will be drawn. To manipulate how much part is visible on the screen, we use _pageController.
  2. In the itemBuilder, based on the index return the MovieCardWidget. Generally, we get 20 movies from the API, so itemBuilder will create 20 cards.
  3. When you're in between a complete scroll transaction, pageSnapping true makes it complete the scroll action.
  4. You should mention the itemCount because itemBuilder will be called only with indices greater than or equal to zero and less than itemCount. In short, it's a for loop with for (int i = 0;i<length;i++). we're doing very safe code here, but still, if movies are null, then this will throw an error. So, use ?? and return 0.
  5. To update the backdrop image and title of the movie below PageView, we'll need to get the callback when the PageView is scrolled.
  6. Wrap the PageView.Builder with Container, so that we can give height and margin to it.
  7. To maintain some space between MovieAppBar and the other details of the movie, we need the vertical margin.
  8. Once we add the animation to the MovieCardWidget, we'll need the height. So, after some calculation 35% of the screen height is the perfect value here. Don't hardcode any heights in this case, using ratios is best. As we used 0.6 for FractionallySizedBox in HomeScreen, 0.35 here makes total sense.

When you run, you'll see the PageView horizontally scrollable with all the MovieCardWidgets touching each other. We need to add animation while scrolling, also give some spacing between each individual MovieCardWidget.

AnimatedMovieCardWidget

This widget will animate the MovieCardWidget's height, using the _pageController's value.

In the presentation/journeys/home/movie_carousel, create a new file animated_movie_card_widget.dart:

class AnimatedMovieCardWidget extends StatelessWidget {
  //1
  final int index;
  final PageController pageController;
  final int movieId;
  final String posterPath;

  const AnimatedMovieCardWidget({
    Key key,
    @required this.index,
    @required this.movieId,
    @required this.posterPath,
    @required this.pageController,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //2
    return MovieCardWidget(
      movieId: movieId,
      posterPath: posterPath,
    );
  }
}
  1. Create a stateless widget with 2 extra fields index and pageController, that will be used to calculate the height.
  2. Just call the MovieCardWidget from here.

We'll now wrap this with AnimatedBuilder and determine the value that will manipulate the height of cards in focus and that not in focus.

If you want a complete explanation, I have already explained similar stuff in AnimatedCarousel video.

Update the build() of AnimatedMovieCardWidget:

@override
Widget build(BuildContext context) {
  //1
  return AnimatedBuilder(
    //2
    animation: pageController,
    builder: (context, child) {
      //3
      double value = 1;
      //4
      if (pageController.position.haveDimensions) {
        value = pageController.page - index;
        value = (1 - (value.abs() * 0.1)).clamp(0.0, 1.0);
        return Align(
          alignment: Alignment.topCenter,
          child: Container(
            //5
            height: Curves.easeIn.transform(value) * ScreenUtil.screenHeight * 0.35,
            width: Sizes.dimen_230.w,
            child: child,
          ),
        );
      } else {
        print('else');
        return Align(
          alignment: Alignment.topCenter,
          child: Container(
            height:
                Curves.easeIn.transform(index == 0 ? value : value * 0.5) *
                    ScreenUtil.screenHeight * 0.35,
            width: Sizes.dimen_230.w,
            child: child,
          ),
        );
      }
    },
    child: MovieCardWidget(
      movieId: movieId,
      posterPath: posterPath,
    ),
  );
}
  1. Wrap the MovieCardWidget with AnimatedBuilder.
  2. In the animation, use the pageController so that when the pageController value changes, the AnimatedBuilder will re-draw the child with builder.
  3. value starts with 1 and when you scroll, the value changes to 0.9 over frames.
  4. If statement executes when you scroll. Else executes in the default state.
  5. For height, we use value to transform the height of the container.

This complete logic is very well explained in the video. Do check it out.

Now, in MoviePageView, instead of MovieCardWidget, use AnimatedMovieCardWidget:

itemBuilder: (context, index) {
  final MovieEntity movie = widget.movies[index];
  return AnimatedMovieCardWidget(
    index: index,
    pageController: _pageController,
    movieId: movie.id,
    posterPath: movie.posterPath,
  );
},

This is self-explanatory. Now run the app, and you'll see the carousel cards animating.

MovieBackdropWidget

What do we want to achieve? This widget is behind the MovieCarouselWidget and shows the backdrop image of the movie in focus. On scrolling the MoviePageView, you load the backdrop image of the movie in focus.

First, create a bloc because the image will change on the scroll.

In the presentation/blocs folder, create a new folder movie_backdrop .

In movie_backdrop_event.dart add MovieBackdropChangedEvent:

//1
class MovieBackdropChangedEvent extends MovieBackdropEvent {
  final MovieEntity movie;

  const MovieBackdropChangedEvent(this.movie);

  @override
  List<Object> get props => [movie];
}
  1. This event will be dispatched when the page changes in MoviePageView. It takes the current movie.

In movie_backdrop_state.dart, create 2 states:

//1
class MovieBackdropInitial extends MovieBackdropState {}

//2
class MovieBackdropChanged extends MovieBackdropState {
  final MovieEntity movie;

  const MovieBackdropChanged(this.movie);

  @override
  List<Object> get props => [movie];
}
  1. This will be the initial state because before any Page changes you cannot load any image.
  2. MovieBackdropChanged is simple again, as it just takes in the movie. In the UI, we'll fetch the backdropPath and title.

In movie_backdrop_bloc.dart, handle the single event:

@override
Stream<MovieBackdropState> mapEventToState(MovieBackdropEvent event) async* {
  //1
  yield MovieBackdropChanged((event as MovieBackdropChangedEvent).movie);
}
  1. This is straight-forward, we're just yielding the state with the movie received from the event.

Register the MovieBackdropBloc in get_it.dart:

getItInstance.registerFactory(() => MovieBackdropBloc());
  1. Register the bloc as Factory.

Update home_screen.dart, to get instance of MovieBackdropBloc, and use MultiBlocProvider now, as we need two Blocs:

class _HomeScreenState extends State<HomeScreen> {
  MovieCarouselBloc movieCarouselBloc;
  MovieBackdropBloc movieBackdropBloc;

  @override
  void initState() {
    super.initState();
    movieCarouselBloc = getItInstance<MovieCarouselBloc>();
    //1
    movieBackdropBloc = getItInstance<movieBackdropBloc>();
    movieCarouselBloc.add(CarouselLoadEvent());
  }

  @override
  void dispose() {
    super.dispose();
    //2
    movieCarouselBloc?.close();
    movieBackdropBloc?.close();
  }

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        //3
        BlocProvider(create: (_) => movieCarouselBloc),
        BlocProvider(create: (_) => movieBackdropBloc),
      ],
      child: Scaffold(),
    );
  }
}
  1. Fetch the instance of MovieBackdropBloc from getIt.
  2. In dispose(), don't forget to close the bloc.
  3. MultiBlocProvider takes an array of BlocProvider, so add one more in the same fashion as movieCarouselBloc.

Now, In movie_page_view.dart, dispatch the MovieBackdropChangedEvent when page changes:

//1
onPageChanged: (index) {
  BlocProvider.of<MovieBackdropBloc>(context)
      //2
      .add(MovieBackdropChangedEvent(widget.movies[index]));
},
  1. Since, home_screen provided the bloc, it can be used in the descendants by using BlocProvider.of(context).
  2. You'll dispatch the event with the movie in focus, with the help of index.

Let's add the UI now in MovieCarouselWidget:

@override
Widget build(BuildContext context) {
  //1
  return Stack(
    fit: StackFit.expand,
    children: [
      //2
      MovieBackdropWidget(),
      //3
      Column(
        children: [
          ...,
          ...,
        ],
      ),
    ],
  );
}
  1. Since the backdrop is behind the MoviePageView, use Stack with the fit as StackFit.expand.
  2. Add the MovieBackdropWidget first to have it in the background.
  3. This column contains the MovieAppBar and MoviePageView as shown previously.

In the presentation/journeys/home/movie_carousel folder, create a new file movie_backdrop_widget.dart:

class MovieBackdropWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //9
    return FractionallySizedBox(
      alignment: Alignment.topCenter,
      heightFactor: 0.7,
      //8
      child: ClipRRect(
        borderRadius: BorderRadius.vertical(bottom: Radius.circular(Sizes.dimen_40.w),),
        //4
        child: Stack(
          children: <Widget>[
            //5
            FractionallySizedBox(
              heightFactor: 1, widthFactor: 1,
              //1
              child: BlocBuilder<MovieBackdropBloc, MovieBackdropState>(
                builder: (context, state) {
                  if (state is MovieBackdropChanged) {
                    //2
                    return CachedNetworkImage(
                      imageUrl: '${ApiConstants.BASE_IMAGE_URL}${state.movie.backdropPath}',
                      fit: BoxFit.fitHeight,
                    );
                  }
                  //3
                  return const SizedBox.shrink();
                },
              ),
            ),
            //6
            BackdropFilter(
              //7
              filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0),
              child: Container(width: ScreenUtil.screenWidth, height: 1, color: Colors.transparent,),
            ),
          ],
        ),
      ),
    );
  }
}
  1. A simple stateless widget with BlocBuilder giving us the selected movie, we can say.
  2. Use CachedNetworkImage with backdropPath and fitHeight.
  3. In case, the state is MovieBackdropInitial, we can't show anything in the UI, so using SizedBox.shrink().
  4. If you noticed, we're using the Frosty Glassy look on the Backdrop Image. For that, we need a layer on top, so use Stack for that.
  5. To allow the Image to take full width and height, let's use the heightFactor and widthFactor in FractionallySizedBox.
  6. The top layer will have BackdropFilter.
  7. Apply a filter with 5.0 as sigmaX and sigmaY. Give the full width to the container to cover the full screen with height as the minimum of 1 and color as transparent. If you give height as 0, the backdrop will not work. If you give any color other than, there will be a strip of that color on top.
  8. We also have to give the bottom radius, so wrap the Stack with ClipRRect with 40.w as the bottom radius.
  9. Also, this backdrop is in a Stack and should not take full height, so wrap this with FractionallySizedBox with 0.7 as heightFactor and top alignment.

Run the app and when you change the page, you'll see the backdrop image. Why are you not seeing the backdrop image before changing the page, at the time when the carousel loads for the first time? Because we are dispatching events only on pageChanged and not at the time of loading movies. Let's do that then.

We need MovieBackdropBloc instance in MovieCarouselBloc, so that we can dispatch MovieBackdropChangedEvent: Update MovieCarouselBloc to accept MovieBackdropBloc in constructor:

class MovieCarouselBloc extends Bloc<MovieCarouselEvent, MovieCarouselState> {
  final GetTrending getTrending;
  //1
  final MovieBackdropBloc movieBackdropBloc;

  MovieCarouselBloc({
    @required this.getTrending,
    //1
    @required this.movieBackdropBloc,
  }) : super(MovieCarouselInitial());

  @override
  Stream<MovieCarouselState> mapEventToState(
    MovieCarouselEvent event,
  ) async* {
    if (event is CarouselLoadEvent) {
      final moviesEither = await getTrending(NoParams());
      yield moviesEither.fold(
        (l) => MovieCarouselError(),
        (movies) {
          //2
          movieBackdropBloc
              .add(MovieBackdropChangedEvent(movies[event.defaultIndex]));
          return MovieCarouselLoaded(
            movies: movies,
            defaultIndex: event.defaultIndex,
          );
        },
      );
    }
  }
}
  1. Declare the movieBackdropBloc as final and use it in the constructor.
  2. Dispatch the event with the movie at defaultIndex, which is 0 at the start. There is a BlocBuilder in MovieBackdropWidget that will receive this event and load the image of the first movie.

In get_it.dart, update the registration of MovieCarouselBloc:

getItInstance.registerFactory(
  () => MovieCarouselBloc(
    getTrending: getItInstance(),
    //1
    movieBackdropBloc: getItInstance(),
  ),
);
  1. Use getItInstance() to provide us with the instance of MovieBackdropBloc in MovieCarouselBloc:

If you run, you'll still see that the backdrop is not loaded until the first page changes. This is happening because of instance resolution. The movieBackdropBloc instance in home_screen and that in movieCarouselBloc is different. They should be the same. There are 2 ways to make them the same.

  • Use Singleton for MovieBackdropBloc
  • Use the movieBackdropBloc from movieCarouselBloc in HomeScreen

We're going with second approach. Open home_screen.dart and update initState():

@override
void initState() {
  super.initState();
  movieCarouselBloc = getItInstance<MovieCarouselBloc>();
  //1
  movieBackdropBloc = movieCarouselBloc.movieBackdropBloc;
  movieCarouselBloc.add(CarouselLoadEvent());
}
  1. Do not take the instance from GetIt, instead take it from MovieCarouselBloc. This same instance will be used in the MovieCarouselBloc to dispatch the MovieBackdropChangedEvent of MovieBackdropBloc.

Now, run the app. You'll see the backdrop image load from initialization.

MovieDataWidget

To show the title of the movie on the current page in MoviePageView, we'll use the MovieBackdropBloc's state:

In the presentation/journeys/home/movie_carousel folder, create a new file movie_data_widget.dart:

class MovieDataWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //1
    return BlocBuilder<MovieBackdropBloc, MovieBackdropState>(
      builder: (context, state) {
        //2
        if (state is MovieBackdropChanged) {
          //3
          return Text(
            state.movie.title,
            textAlign: TextAlign.center,
            maxLines: 1,
            //3
            overflow: TextOverflow.fade,
            //4
            style: Theme.of(context).textTheme.headline6,
          );
        }
        return const SizedBox.shrink();
      },
    );
  }
}
  1. Use BlocBuilder for MovieBackdropBloc.
  2. If state is MovieBackdropChanged, then show the text with the movie title. To properly layout the title, you can restrict the number of lines.
  3. Use the overflow property, in case text doesn't fit in one line.
  4. Use the headline6 font style, that we created in ThemeText. Always use from Theme.of(context), so that when you change the theme to dark or any other theme, the app remains consistent.

Add the MovieDataWidget below MoviePageView in MovieCarouselWidget:

Column(
  children: [
    MovieAppBar(),
    MoviePageView(
      movies: movies,
      initialPage: defaultIndex,
    ),
    //1
    MovieDataWidget(),
  ],
),

Separator

In the next tutorial, I'll show the tabbed view at the bottom. To make it separate from the carousel, let's add a simple separator.

In the presentation/widgets folder, create a new file separator.dart:

class Separator extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    //1
    return Container(
      height: Sizes.dimen_1.h,
      width: Sizes.dimen_80.w,
      //2
      padding: EdgeInsets.only(
        top: Sizes.dimen_2.h,
        bottom: Sizes.dimen_6.h,
      ),
      //3
      decoration: BoxDecoration(
        borderRadius: BorderRadius.all(Radius.circular(Sizes.dimen_1.h)),
        gradient: LinearGradient(
          colors: [
            AppColor.violet,
            AppColor.royalBlue,
          ],
        ),
      ),
    );
  }
}
  1. A simple container with width and height. We could've used Divider, but the divider doesn't have the radius and uses indents to decide the width.
  2. Padding from top and bottom for separation.
  3. To make round edges, use BoxDecoration with BorderRadius. Give a simple gradient to the separator.

Run the app for the final time and play with it.

This was all about creating a carousel and top part in HomeScreen with the initial setup. See you in the next part of the series.

Did you find this article valuable?

Support Prateek Sharma by becoming a sponsor. Any amount is appreciated!