Hello, Welcome back. Glad that you’re here.
We're building a Movie App with the best coding practices and tools. In the previous tutorial, we created an Animated Movie Carousel.
In this tutorial, we'll create the bottom part in HomeScreen that contains Tabs.
MovieTabBloc
The 3 tabs load 3 different sets of movies. When there is a tap on any tab, we make an API call and return with the respective movies. After returning with movies, UI should update itself. To achieve this, let's start with the bloc that will handle the state management for us.
In presentation/blocs folder, create a new bloc using the extension. Name it movie_tabbed. In movie_tabbed_event.dart, add a single event:
//1
class MovieTabChangedEvent extends MovieTabbedEvent {
//2
final int currentTabIndex;
//3
const MovieTabChangedEvent({this.currentTabIndex = 0});
//4
@override
List<Object> get props => [currentTabIndex];
}
MovieTabChangedEvent
extendsMovieTabEvent
as Bloc's will emitMovieTabEvent
type of event inmapEventToState
.currentTabIndex
will store the index of the tab selected. Using index will make it scalable to add more tabs in the future.- A
const
constructor to keep the current tab at the 0th index. - Use the
currentTabIndex
inprops
so that the event doesn't dispatch when the same tab changes.
In movie_tabbed_state.dart, update the abstract state to handle the current tab index, as it will be required in every state to manage the active tab:
abstract class MovieTabbedState extends Equatable {
//1
final int currentTabIndex;
//2
const MovieTabbedState({this.currentTabIndex});
//3
@override
List<Object> get props => [currentTabIndex];
}
- Create the
currentTabIndex
field, as we did in the MovieTabChangedEvent event. - Add it to the constructor as well.
- Again in
props
, add this field. By doing this, if you tap on the same tab, the bloc will not emit the state.
Now, add 2 more states for success and error:
//1
class MovieTabChanged extends MovieTabbedState {
//2
final List<MovieEntity> movies;
//3
const MovieTabChanged({int currentTabIndex, this.movies})
//4
: super(currentTabIndex: currentTabIndex);
//5
@override
List<Object> get props => [currentTabIndex, movies];
}
//6
class MovieTabLoadError extends MovieTabbedState {
//7
const MovieTabLoadError({int currentTabIndex})
//8
: super(currentTabIndex: currentTabIndex);
}
- When you change the tab, you'll emit the
MovieTabChanged
state. - Declare a list of movies variable to store the movies.
- A
const
constructor withcurrentTabIndex
and movies. We're usingcurrentTabIndex
from theabstract
class, so you need not use this here. - User
super
to assign the child classcurrentTabIndex
to the abstract classcurrentTabIndex
. - Use
props
and assign both thecurrentTabIndex
andmovies
. - When there is an error in fetching the movies, you'll emit the
MovieTabLoadError
state. - A
const
constructor withcurrentTabIndex
only. - Also, assign the
currentTabIndex
to the superclass.
In movie_tabbed_bloc.dart handle the events:
class MovieTabbedBloc extends Bloc<MovieTabbedEvent, MovieTabbedState> {
//1
final GetPopular getPopular;
final GetPlayingNow getPlayingNow;
final GetComingSoon getComingSoon;
MovieTabbedBloc({
@required this.getPopular,
@required this.getPlayingNow,
@required this.getComingSoon,
}) : super(MovieTabbedInitial());
@override
Stream<MovieTabbedState> mapEventToState(
MovieTabbedEvent event,
) async* {
//2
if (event is MovieTabChangedEvent) {
//3
Either<AppError, List<MovieEntity>> moviesEither;
//4
switch (event.currentTabIndex) {
//5
case 0:
moviesEither = await getPopular(NoParams());
break;
//6
case 1:
moviesEither = await getPlayingNow(NoParams());
break;
//7
case 2:
moviesEither = await getComingSoon(NoParams());
break;
}
//8
yield moviesEither.fold(
//9
(l) => MovieTabLoadError(currentTabIndex: event.currentTabIndex),
//10
(movies) {
return MovieTabChanged(
currentTabIndex: event.currentTabIndex,
movies: movies,
);
},
);
}
}
}
- Declare the remaining 3 usecases,
GetPopular
,GetPlayingNow
, andGetComingSoon
, and use them in the constructor. - We are only working on
MovieTabChangedEvent
, so have only oneif
. This will also auto-cast the event to theMovieTabChangedEvent
type. - Use
Either
similar to that inMovieCarouselBloc
, because the usecases return the same type. - Use
switch
forcurrentTabIndex
to call separate usecases based on the current tab. - Our 1st tab is Populars, so use the
GetPopular
usecase. - The 2nd tab is Now, so use the
GetPlayingNow
usecase. - The 3rd tab is soon, so use the
GetComingSoon
usecase. - Again use the
fold
operator. - When there is an error, emit the
MovieTabLoadError
. I will show in later tutorials, how to handle errors in UI. - Where there is a success, emit the
MovieTabChanged
, with the current tab and respective movies.
Register The Bloc
In get_it.dart, register the MovieTabBloc:
getItInstance.registerFactory(
() => MovieTabbedBloc(
getPopular: GetPopular(getItInstance()),
getComingSoon: GetComingSoon(getItInstance()),
getPlayingNow: GetPlayingNow(getItInstance()),
),
);
Home Screen
Let's use the bloc in HomeScreen before we create the UI.
//1
MovieTabbedBloc movieTabbedBloc;
//2
movieTabbedBloc = getItInstance<MovieTabbedBloc>();
//3
movieTabbedBloc?.close();
//4
BlocProvider(
create: (context) => movieTabbedBloc,
),
- Declare a variable as the other blocs in
HomeScreen
. - Get the instance of the bloc from GetIt.
- Don't forget to dispose of the bloc in
dispose
. - Like other blocs in the MultiBlocProvider, add
MovieTabbedBloc
as well. This bloc will be used in descendants like MovieTabbedWidget(later in the tutorial).
Text Style
In this tutorial, we're adding tabs and the title of the movie again. And, we haven't added those text styles in out text theme, so let's add that. Open theme_text.dart:
//1
static TextStyle get whiteSubtitle1 => _poppinsTextTheme.subtitle1.copyWith(
fontSize: Sizes.dimen_16.sp,
color: Colors.white,
);
static TextStyle get whiteBodyText2 => _poppinsTextTheme.bodyText2.copyWith(
color: Colors.white,
fontSize: Sizes.dimen_14.sp,
wordSpacing: 0.25,
letterSpacing: 0.25,
height: 1.5,
);
static getTextTheme() => TextTheme(
headline6: _whiteHeadline6,
//3
subtitle1: whiteSubtitle1,
bodyText2: whiteBodyText2,
);
- Create a
subtitle1
as per the material guidelines I showed in the last tutorial. For unselected tabs, we have a white color. - For each movie card we have a title with the size of 14 and white color. According to material guidelines, I am also adding
letterSpacing
,wordSpacing
, andheight
. - In the
TextTheme
now add these two text styles.
How do you add text styles for the selected tab? There are limited reasons in TextTheme
, for obvious reasons. When we've to deal with extra colors for the same text styles, we can create an extension that runs on TextTheme
.
//1
extension ThemeTextExtension on TextTheme {
//2
TextStyle get royalBlueSubtitle1 => subtitle1.copyWith(
color: AppColor.royalBlue,
fontWeight: FontWeight.w600,
);
}
//3
Theme.of(context).textTheme.royalBlueSubtitle1
- Create
extension
onTextTheme
- Create a
TextStyle
copyingsubtitle1
with theroyalBlue
color andfontWeight
. - This is how you'll use this text style. This way we can keep the uniformity and handle other text themes as well.
Tabs
Just before we start with the UI, let's add the tab text and model to hold the index and tab text.
In presentation/journeys/home/movie_tabbed, create a new file tab.dart:
//1
class Tab {
//2
final int index;
final String title;
//3
const Tab({
@required this.index,
@required this.title,
}) : assert(index > 0, 'index cannot be negative'),
assert(title != null, 'title cannot be null');
}
- A model class.
- Declare two fields
index
andtitle
to store the index and name of the tab. - Create a
const
constructor with both fields as required and add normal assertions for safety.
In presentation/journeys/home/movie_tabbed, create a new file movie_tabbed_constants.dart:
//1
class MovieTabbedConstants {
//2
static const List<Tab> movieTabs = const [
const Tab(index: 0, title: 'Popular'),
const Tab(index: 1, title: 'Now'),
const Tab(index: 2, title: 'Soon'),
];
}
- Create a constant class.
- Create a list of tabs and add 3 tabs with the index.
Tab Title Widget
We cannot use the tabs from Material Library, because that is not flexible, and the UI that we want to achieve cannot be achieved by Tabs. (At least, as per my knowledge)
In presentation/journeys/home/movie_tabbed, create a new file tab_title_widget.dart:
class TabTitleWidget extends StatelessWidget {
//1
final String title;
//2
final Function onTap;
//3
final bool isSelected;
//4
const TabTitleWidget({
Key key,
@required this.title,
@required this.onTap,
this.isSelected = false,
}) : //5
assert(title != null, 'title should not be null'),
assert(onTap != null, 'onTap should not be null'),
assert(isSelected != null, 'isSelected should not be null'),
super(key: key);
@override
Widget build(BuildContext context) {
//10
return GestureDetector(
onTap: onTap,
//8
child: Container(
decoration: BoxDecoration(
color: Colors.transparent,
//9
border: Border(
bottom: BorderSide(
color: isSelected ? AppColor.royalBlue : Colors.transparent,
width: Sizes.dimen_1.h,
),
),
),
//6
child: Text(
title,
//7
style: isSelected
? Theme.of(context).textTheme.royalBlueSubtitle1
: Theme.of(context).textTheme.subtitle1,
),
),
);
}
}
- We'll need the title to put the text.
- Provide a function holder, that will be invoked when we tap on the tab.
- To maintain the selected state, we'll also have the
isSelected
field. - We keep
false
as the default value forisSelected
. - Add valid assertions for all the fields.
- Use the
Text
widget to show the tab title. - Based on
isSelected
, we also change the text styles. - Using
Container
to provide the border for the selected tab. We could have usedColumn
, but that would increase the number of widgets. - We give the bottom border only with a change in color for the selected and unselected state. Give a minimal width as well.
GestureDetector
to make the Tab tappable, with theonTap
function passed from the caller.
Movie Tab Card Widget
Before we move to create the horizontal listview to show the movies, let's create the movie card first. This movie card will have an image and title below that.
In presentation/journeys/home/movie_tabbed, create a new file movie_tab_card_widget.dart:
class MovieTabCardWidget extends StatelessWidget {
//1
final int movieId;
//2
final String title, posterPath;
const MovieTabCardWidget({
Key key,
@required this.movieId,
@required this.title,
@required this.posterPath,
}) : super(key: key);
@override
Widget build(BuildContext context) {
//10
return GestureDetector(
onTap: () {},
//3
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
//4
Expanded(
//5
child: ClipRRect(
borderRadius: BorderRadius.circular(Sizes.dimen_16.w),
//6
child: CachedNetworkImage(
imageUrl: '${ApiConstants.BASE_IMAGE_URL}$posterPath',
fit: BoxFit.cover,
),
),
),
//7
Padding(
padding: EdgeInsets.only(top: Sizes.dimen_4.h),
//8
child: Text(
title,
//9
maxLines: 1,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyText2,
),
),
],
),
);
}
}
- We need
movieId
in the future for loading movie details. posterPath
to load the image andtitle
for showing the name of the movie below the image.- To layout in image and title, use
Column
with horizontal center alignment. So that both image and title are in the center horizontally. - The image will take the most of the available space, so use
Expanded
. - Use
ClipRRect
for clipping with a border to the image. - Use
CachedNetworkImage
to load the poster image. - To give some space in between the image and title, add a
Padding
widget. - Text widget to show the title of the movie.
- We want to have a single line of the title, with text aligned as a center and bodyText2 text style.
- Wrap this whole widget in
GestureDetector
to handle the tap. On tap of this, we will land on Movie Detail Screen.
Movie List View Widget
Let's create a horizontal list that will show the movies.
In presentation/journeys/home/movie_tabbed, create a new file movie_list_view_builder.dart:
class MovieListViewBuilder extends StatelessWidget {
//1
final List<MovieEntity> movies;
const MovieListViewBuilder({Key key, @required this.movies}) : super(key: key);
@override
Widget build(BuildContext context) {
//9
return Padding(
padding: EdgeInsets.symmetric(vertical: 6.h),
//2
child: ListView.separated(
//3
shrinkWrap: true,
//4
itemCount: movies.length,
//5
scrollDirection: Axis.horizontal,
//6
separatorBuilder: (context, index) {
return SizedBox(width: 14.w,);
},
//7
itemBuilder: (context, index) {
//8
final MovieEntity movie = movies[index];
return MovieTabCardWidget(
movieId: movie.id,
title: movie.title,
posterPath: movie.posterPath,
);
},
),
);
}
}
- A stateless widget that requires a list of movies.
- Use
ListView.separated
to add some space in between each card. Rest all is similar toListView.Builder
. - Use
shrinkWrap
to avoid any render flow exceptions. - Tell the ListView about several movies it has to draw.
- As this is a horizontal listview, give the
scroll direction as
Axis.horizontal`. - Use
separatorBuilder
to return aSizedBox
of some width, to give margin in between cards. - Now, build each card by using
itemBuilder
. - Fetch a single movie and return the
MovieTabCardWidget
. - To give some top and bottom spacing in between tabs and the listview, we use the
Padding
widget.
Movie Tabbed Widget
This is the final widget that uses the Bloc, Tab Titles, and the ListView, that we created earlier.
In presentation/journeys/home/movie_tabbed, create a new file movie_tabbed_widget.dart:
class MovieTabbedWidget extends StatefulWidget {
@override
_MovieTabbedWidgetState createState() => _MovieTabbedWidgetState();
}
class _MovieTabbedWidgetState extends State<MovieTabbedWidget> with SingleTickerProviderStateMixin {
//1
MovieTabbedBloc get movieTabbedBloc => BlocProvider.of<MovieTabbedBloc>(context);
//2
int currentTabIndex = 0;
@override
void initState() {
super.initState();
//3
movieTabbedBloc.add(MovieTabChangedEvent(currentTabIndex: currentTabIndex));
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
//4
return BlocBuilder<MovieTabbedBloc, MovieTabbedState>(
builder: (context, state) {
//5
return Padding(
padding: EdgeInsets.only(top: Sizes.dimen_4.h),
//6
child: Column(
children: <Widget>[
//7
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
//8
for (var i = 0; i < MovieTabbedConstants.movieTabs.length; i++)
//9
TabTitleWidget(
title: MovieTabbedConstants.movieTabs[i].title,
onTap: () => _onTabTapped(i),
isSelected: MovieTabbedConstants.movieTabs[i].index == state.currentTabIndex,
)
],
),
//10
if (state is MovieTabChanged)
Expanded(
child: MovieListViewBuilder(movies: state.movies),
),
],
),
);
},
);
}
//11
void _onTabTapped(int index) {
movieTabbedBloc.add(MovieTabChangedEvent(currentTabIndex: index));
}
}
- Use
getter
to get the instance ofMovieTabBloc
from ancestors. - Declare the current tab index as 0, because by default we'll show the first tab and movies.
- Dispatch the
MovieTabChangedEvent
to fetch the Popular movies. - Since the state of tabs & listview changes on tapping any tab, we'll use BlocBuilder, which will rebuild the child.
- For some vertical padding top and bottom, we'll use vertical padding.
- As the tabs are above the listview, we use Column.
- All the tabs are in the horizontal direction, so we're using Row.
- Using the awesome feature of the dart, we can use the
for
loop to build widgets in an array. Run the loop for the number of movies. - Use the
TabTitleWidget
by using the index. Take title from constants, Call a function with the index that will dispatch theMovieTabChangedEvent
with the respective index. To decide whether the tab is in the selected state or not, we compare the index from the state with the index of the tab. - Below the tabs, we will only build ListView when loading of movies is a success i.e.
MovieTabChanged
state. So show the listview in the Expanded widget so that it takes the available space. - Lastly, you can create a function, that just asks
MovieTabbedBloc
to dispatch theMovieTabChangedEvent
with the index.
Open home_screen.dart and replace the PlaceHolder
with MovieTabbedWidget
.
String Extension
When you run the application, you'll see that the title of the movies are very long and are increasing the overall space taken by one movie. This is unexpected. We can solve this by reducing the number of characters that can be shown in the title. We'll create an extension for this and take only 15 characters for the title.
In common/extensions folder, create a new file string_extensions.dart:
//1
extension StringExtension on String {
//2
String intelliTrim() {
//3
return this.length > 15 ? '${this.substring(0, 15)}...' : this;
}
}
- Create an extension on
String
. - Declare a function that you can call on any string.
- Check if the length of the string is more than 15. If it is more than 15 then you can take the first 15 letters. Otherwise, take the complete string. This way, we only trim the strings which are more than the allowed length.
You can also make this function take dynamic number characters that are to be taken. This way you'll make this function more generic. For now, I am keeping it as it is.
Now, use this function in the MovieTabCardWidget
Text(
title.intelliTrim(),
),
The VSCode is not good at dart extensions, as far as I know. The extensions are not available for auto-imports. If you know the solution, please let me know.
Run the app for the final time and play with it. You can see how in all the phones the home screen is exactly similar and there are no render flow exceptions and UI scaling.
This was all about creating Tabs and loading different movies in the bottom part of HomeScreen. See you in the next part of the series.