6. Homescreen Tabs

6. Homescreen Tabs

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];
}
  1. MovieTabChangedEvent extends MovieTabEvent as Bloc's will emit MovieTabEvent type of event in mapEventToState.
  2. currentTabIndex will store the index of the tab selected. Using index will make it scalable to add more tabs in the future.
  3. A const constructor to keep the current tab at the 0th index.
  4. Use the currentTabIndex in props 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];
}
  1. Create the currentTabIndex field, as we did in the MovieTabChangedEvent event.
  2. Add it to the constructor as well.
  3. 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);
}
  1. When you change the tab, you'll emit the MovieTabChanged state.
  2. Declare a list of movies variable to store the movies.
  3. A const constructor with currentTabIndex and movies. We're using currentTabIndex from the abstract class, so you need not use this here.
  4. User super to assign the child class currentTabIndex to the abstract class currentTabIndex.
  5. Use props and assign both the currentTabIndex and movies.
  6. When there is an error in fetching the movies, you'll emit the MovieTabLoadError state.
  7. A const constructor with currentTabIndex only.
  8. 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,
          );
        },
      );
    }
  }
}
  1. Declare the remaining 3 usecases, GetPopular, GetPlayingNow, and GetComingSoon, and use them in the constructor.
  2. We are only working on MovieTabChangedEvent, so have only one if. This will also auto-cast the event to the MovieTabChangedEvent type.
  3. Use Either similar to that in MovieCarouselBloc, because the usecases return the same type.
  4. Use switch for currentTabIndex to call separate usecases based on the current tab.
  5. Our 1st tab is Populars, so use the GetPopular usecase.
  6. The 2nd tab is Now, so use the GetPlayingNow usecase.
  7. The 3rd tab is soon, so use the GetComingSoon usecase.
  8. Again use the fold operator.
  9. When there is an error, emit the MovieTabLoadError. I will show in later tutorials, how to handle errors in UI.
  10. 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,
),
  1. Declare a variable as the other blocs in HomeScreen.
  2. Get the instance of the bloc from GetIt.
  3. Don't forget to dispose of the bloc in dispose.
  4. 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,
);
  1. Create a subtitle1 as per the material guidelines I showed in the last tutorial. For unselected tabs, we have a white color.
  2. 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, and height.
  3. 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
  1. Create extension on TextTheme
  2. Create a TextStyle copying subtitle1 with the royalBlue color and fontWeight.
  3. 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');
}
  1. A model class.
  2. Declare two fields index and title to store the index and name of the tab.
  3. 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'),
  ];
}
  1. Create a constant class.
  2. 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,
        ),
      ),
    );
  }
}
  1. We'll need the title to put the text.
  2. Provide a function holder, that will be invoked when we tap on the tab.
  3. To maintain the selected state, we'll also have the isSelected field.
  4. We keep false as the default value for isSelected.
  5. Add valid assertions for all the fields.
  6. Use the Text widget to show the tab title.
  7. Based on isSelected, we also change the text styles.
  8. Using Container to provide the border for the selected tab. We could have used Column, but that would increase the number of widgets.
  9. We give the bottom border only with a change in color for the selected and unselected state. Give a minimal width as well.
  10. GestureDetector to make the Tab tappable, with the onTap 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,
            ),
          ),
        ],
      ),
    );
  }
}
  1. We need movieId in the future for loading movie details.
  2. posterPath to load the image and title for showing the name of the movie below the image.
  3. To layout in image and title, use Column with horizontal center alignment. So that both image and title are in the center horizontally.
  4. The image will take the most of the available space, so use Expanded.
  5. Use ClipRRect for clipping with a border to the image.
  6. Use CachedNetworkImage to load the poster image.
  7. To give some space in between the image and title, add a Padding widget.
  8. Text widget to show the title of the movie.
  9. We want to have a single line of the title, with text aligned as a center and bodyText2 text style.
  10. 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,
          );
        },
      ),
    );
  }
}
  1. A stateless widget that requires a list of movies.
  2. Use ListView.separated to add some space in between each card. Rest all is similar to ListView.Builder.
  3. Use shrinkWrap to avoid any render flow exceptions.
  4. Tell the ListView about several movies it has to draw.
  5. As this is a horizontal listview, give the scroll direction asAxis.horizontal`.
  6. Use separatorBuilder to return a SizedBox of some width, to give margin in between cards.
  7. Now, build each card by using itemBuilder.
  8. Fetch a single movie and return the MovieTabCardWidget.
  9. 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));
  }
}
  1. Use getter to get the instance of MovieTabBloc from ancestors.
  2. Declare the current tab index as 0, because by default we'll show the first tab and movies.
  3. Dispatch the MovieTabChangedEvent to fetch the Popular movies.
  4. Since the state of tabs & listview changes on tapping any tab, we'll use BlocBuilder, which will rebuild the child.
  5. For some vertical padding top and bottom, we'll use vertical padding.
  6. As the tabs are above the listview, we use Column.
  7. All the tabs are in the horizontal direction, so we're using Row.
  8. 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.
  9. Use the TabTitleWidget by using the index. Take title from constants, Call a function with the index that will dispatch the MovieTabChangedEvent 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.
  10. 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.
  11. Lastly, you can create a function, that just asks MovieTabbedBloc to dispatch the MovieTabChangedEvent 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;
  }
}
  1. Create an extension on String.
  2. Declare a function that you can call on any string.
  3. 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.

Did you find this article valuable?

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