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);
}
- Create a class AppColor.
- Add a private constructor, since it is not required to instantiate the class.
- 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());
}
- You don't need async now, as we're using
unawaited
. In the future, we'll need it when we do Hive initialization. - As per official Flutter documentation, this is the glue that binds the framework to the Flutter engine.
- 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. - Initialize GetIt to provide us with dependencies.
- 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;
- Here,
defaultWidth
anddefaultHeight
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 thedefaultWidth
anddefaultHeight
, you can do so by invokingScreenUtil.init(width: 414,height: 896,);
before returningMaterialApp
. - 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,
);
}
- We're using
Poppins
Font. - 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. - 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(),
);
}
}
- Initialize ScreenUtil so that we can use it while defining
- Use the
MaterialApp
widget with debugBanner as false or true. - Define
ThemeData
of the app. Make Vulcan asprimary
as well asscaffoldBackground
color. All our screens have Vulcan as their background color. Also, give the text theme. - 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.
Movie Carousel Bloc
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];
}
- 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. defaultIndex
will give us the flexibility to decide which movie will be in the center of our carousel at the start.- A
const
constructor withdefaultIndex
as 0, if not passed. 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];
}
MovieCarouselInitial
to be emitted as the first state when the bloc initializes.MovieCarouselError
to be emitted if there is an error thrown from API. I'll not observe this state in this tutorial.MovieCarouselLoaded
to be emitted with a list of trending movies and default index, which is passed fromCarouselLoadEvent
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
}
}
- GetTrending will be the final variable.
- Clearly,
MovieCarouselBloc
is dependent onGetTrending
, 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,
);
},
);
}
- Handle if the event dispatched is
CarouselLoadEvent
- Call the
getTrending
usecase withNoParams()
- Use the
fold
operator to handle the response - When an error(left), yield error state (Future-proof), not handling this state in this tutorial.
- 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.
- Home Screen
- Logo Widget
- Movie App Bar Widget
- Movie Carousel Widget
- Movie Card Widget
- Movie Page View Widget
- Animated Movie Card Widget
- Movie Backdrop Widget
- Movie Data Widget
- 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
}
}
- Initialize the
MovieCarouselBloc
from GetIt. - When the home screen initializes, dispatch the only event for
MovieCarouselBloc
This will make an API call and yield theMovieCarouselLoaded
orMovieCarouselError
state. - 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),
),
],
),
);
}
- When you use FractionallySizedBox, you should use
StackFit.expand
, because this allows the stack to take the available space. - FractionallySizedBox uses fractions to decide on the proportion of screen that it will take.
- The top part should have
topCenter
as its alignment. - 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
. - The bottom FractionallySizedBox, obviously will have
bottomCenter
as its alignment. - The
heightFactor
of this remaining part of the screen will be0.4
. MovieTabbedWidget
will replace thePlaceholder
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,
);
}
}
Logo
is a stateless widget with a dynamic height that will be provided by the calling widget. This is important here because the sameLogo
widget will be used in theNavigationDrawer
when we implement that.- 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.
- 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),
),
],
),
);
}
}
- 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. - Use
Row
to layout the elements in horizontal. - In Row, at start and end add the 2
IconButtons
. One being SvgPicture and the other being taken from the Flutter framework itself. - 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();
},
),
),
);
}
- Use
BlocProvider
to provide theMovieCarouselBloc
instance down the tree. - You need not create the bloc here as it is already done in
initState()
. - Use
BlocBuilder
to read the current state ofMovieCarouselBloc
. Thebuilder
takes incontext
andstate
. - When loading trending movies are a success, we show the previously used
Stack
having two sections - Top and Bottom. Give theMovieCarouselWidget
with instead of the firstPlaceHolder
. - 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,
),
],
);
}
}
MovieCarouselWidget
is a stateless widget, that works on the list ofmovies
and thedefaultIndex
.- 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. - A column with just 2 elements. The first being the
MovieAppBar
that we created before. - Next in Column is
MoviePageView
, belowMovieAppBar
that we'll create now. This widget also takes in the list ofmovies
and thedefaultIndex
.
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,
),
),
),
);
}
}
- You'll need
movieId
in the future when we tap on this card to move to the movie detail screen. - The
posterPath
is required to load the image. This will be taken fromMovieEntity
and will be in this format kqjL17yufvn9OVLyXYpvtyrFfak.jpg. - Use CachedNetworkImage with the imageUrl prepended with
BASE_IMAGE_URL
. In DataSources, I have explained the use ofBASE_IMAGE_URL
. - Use
ClipRRect
to clip the image, with aborderRadius
. This will add the curves on all the vertices of the images. - Use
GestureDetector
to enable tappable events on the card. - 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
}
}
- Create a stateful widget with a list of
movies
andinitialPage
. TheinitialPage
is the same asdefaultIndex
, so apply the same assertion to this as well. - In the
State
class of MoviePageView, declare aPageController
. - In
initState()
, instantiate _pageController
withviewportFraction
as 0.7.viewportFraction
decides how much screen share each item ofPageView
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) {},
),
);
}
- 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
. - In the
itemBuilder
, based on the index return theMovieCardWidget
. Generally, we get 20 movies from the API, soitemBuilder
will create 20 cards. - When you're in between a complete scroll transaction,
pageSnapping
true
makes it complete the scroll action. - You should mention the itemCount because
itemBuilder
will be called only with indices greater than or equal to zero and less thanitemCount
. In short, it's afor
loop withfor (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 return0
. - To update the backdrop image and title of the movie below
PageView
, we'll need to get the callback when thePageView
is scrolled. - Wrap the
PageView.Builder
with Container, so that we can give height and margin to it. - To maintain some space between MovieAppBar and the other details of the movie, we need the vertical margin.
- 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
inHomeScreen
,0.35
here makes total sense.
When you run, you'll see the PageView
horizontally scrollable with all the MovieCardWidget
s 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,
);
}
}
- Create a stateless widget with 2 extra fields
index
andpageController
, that will be used to calculate the height. - 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,
),
);
}
- Wrap the
MovieCardWidget
withAnimatedBuilder
. - In the
animation
, use thepageController
so that when thepageController
value changes, the AnimatedBuilder will re-draw the child withbuilder
. value
starts with 1 and when you scroll, the value changes to 0.9 over frames.If
statement executes when you scroll.Else
executes in the default state.- For
height
, we usevalue
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];
}
- 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];
}
- This will be the initial state because before any Page changes you cannot load any image.
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);
}
- 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());
- 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(),
);
}
}
- Fetch the instance of
MovieBackdropBloc
from getIt. - In
dispose()
, don't forget to close the bloc. MultiBlocProvider
takes an array of BlocProvider, so add one more in the same fashion asmovieCarouselBloc
.
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]));
},
- Since, home_screen provided the bloc, it can be used in the descendants by using
BlocProvider.of(context)
. - 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: [
...,
...,
],
),
],
);
}
- Since the backdrop is behind the
MoviePageView
, use Stack with the fit asStackFit.expand
. - Add the
MovieBackdropWidget
first to have it in the background. - This column contains the
MovieAppBar
andMoviePageView
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,),
),
],
),
),
);
}
}
- A simple stateless widget with
BlocBuilder
giving us the selected movie, we can say. - Use
CachedNetworkImage
withbackdropPath
andfitHeight
. - In case, the state is
MovieBackdropInitial
, we can't show anything in the UI, so usingSizedBox.shrink()
. - 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.
- To allow the Image to take full width and height, let's use the
heightFactor
andwidthFactor
inFractionallySizedBox
. - The top layer will have BackdropFilter.
- Apply a filter with 5.0 as
sigmaX
andsigmaY
. 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. - We also have to give the bottom radius, so wrap the Stack with ClipRRect with
40.w
as the bottom radius. - Also, this backdrop is in a Stack and should not take full height, so wrap this with FractionallySizedBox with
0.7
asheightFactor
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,
);
},
);
}
}
}
- Declare the
movieBackdropBloc
as final and use it in the constructor. - Dispatch the event with the movie at
defaultIndex
, which is 0 at the start. There is a BlocBuilder inMovieBackdropWidget
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(),
),
);
- Use
getItInstance()
to provide us with the instance ofMovieBackdropBloc
inMovieCarouselBloc
:
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
frommovieCarouselBloc
inHomeScreen
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());
}
- Do not take the instance from GetIt, instead take it from
MovieCarouselBloc
. This same instance will be used in theMovieCarouselBloc
to dispatch theMovieBackdropChangedEvent
ofMovieBackdropBloc
.
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();
},
);
}
}
- Use
BlocBuilder
forMovieBackdropBloc
. - If
state
isMovieBackdropChanged
, then show the text with the movie title. To properly layout the title, you can restrict the number of lines. - Use the
overflow
property, in case text doesn't fit in one line. - Use the
headline6
font style, that we created inThemeText
. Always use fromTheme.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,
],
),
),
);
}
}
- 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. - Padding from top and bottom for separation.
- To make round edges, use
BoxDecoration
withBorderRadius
. 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.