Movie App

I choose a movie information & comments app (Android) as my first side project, in this series, I will introduce the whole project including features, UI design, coding, let’s start it.

In this part, there are three topics to show you:

  1. Functional Map
  2. Flow
  3. UI Design

Functional Map & Flow

Functional Map
UI Flow

The features in this app is quite simple, just show movie list in front page, and show the detail information, photos, trailers, and comments of movie when clicking one of the movie items in list.

UI Design

Sketch
UI Flow with real design

I use Sketch 3 as my UI design tool, just create a new project from Material Design template.

The following list is the component I used in the project, because this project was finished before Google released the Android Design Support Library, there are some components can be replaced by new material design widgets:

Movie List

  1. android.support.v7.widget.Toolbar and MaterialTab on the top.
  2. android.support.v7.widget.CardView for each movie in the list.
  3. FAB to sort the movies.
  4. SuperRecyclerView to show move list.

Movie Detail

  1. ObservableScrollView for the whole page to interact with toolbar when scrolling the page.
  2. Material which provides some material design components to pre-Lolipop Android.

Photo List

Enter a caption
  1. ListBuddiesLayout for photo list.

My development tools is Android Studio 1.4. So let’s start from the front page.

Here are the components or libraries I used.

First step is to create a new project with Navigation Drawer Activity, then put the dependencies to build.gradle and sync:

compile fileTree(dir: 'libs', include: ['*.jar'])
compile project(':superRecyclerView')
compile 'com.android.support:appcompat-v7:22.0.0'
compile 'com.jakewharton:butterknife:6.1.0'
compile 'com.balysv:material-ripple:1.0.1'
compile 'com.squareup.picasso:picasso:2.5.2'
compile 'de.hdodenhof:circleimageview:1.2.2'
compile 'com.github.rey5137:material:1.0.0'
compile 'it.neokree:MaterialTabs:0.11'
compile 'com.loopj.android:android-async-http:1.4.6'
compile 'com.orhanobut:logger:1.4'
compile 'com.google.code.gson:gson:2.3.1'
compile 'com.github.castorflex.smoothprogressbar:library:1.0.0'
view raw build.gradle hosted with ❤ by GitHub

The following diagram is the front page activity architecture, a MainActivity to hold a MoviePageFragment which included the MaterialTabs and ViewPager in it, and a MovieListFragment for each tab in ViewPager.

Based on the activity architecture, we create the layout files:

<!-- A DrawerLayout is intended to be used as the top-level content view using match_parent for both width and height to consume the full space available. -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<include
layout="@layout/toolbar"
android:id="@+id/toolbar"/>
<android.support.v4.widget.DrawerLayout
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- As the main content view, the view below consumes the entire
space available using match_parent in both dimensions. -->
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<!-- android:layout_gravity="start" tells DrawerLayout to treat
this as a sliding drawer on the left side for left-to-right
languages and on the right side for right-to-left languages.
If you're not building against API 17 or higher, use
android:layout_gravity="left" instead. -->
<!-- The drawer is given a fixed width in dp and extends the full height of
the container. -->
<fragment
android:id="@+id/navigation_drawer"
android:layout_width="@dimen/navigation_drawer_width"
android:layout_height="match_parent"
android:layout_gravity="start"
android:name="com.moviebomber.ui.fragment.NavigationDrawerFragment"
tools:layout="@layout/fragment_navigation_drawer"/>
</android.support.v4.widget.DrawerLayout>
</LinearLayout>
view raw activity_main.xml hosted with ❤ by GitHub
<com.malinskiy.superrecyclerview.SuperRecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="2dp"
app:layout_progress="@layout/progress"
app:layout_moreProgress="@layout/footer_progress"
app:mainLayoutId="@layout/layout_recyclerview_verticalscroll"
app:recyclerClipToPadding="false"
app:scrollbarStyle="insideOverlay"
tools:context="com.moviebomber.ui.fragment.MovieListFragment"
android:id="@+id/list_movie"
/>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:fab="http://schemas.android.com/apk/res-auto"
xmlns:ads="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.moviebomber.ui.fragment.MoviePageFragment">
<View
android:layout_width="match_parent"
android:layout_height="96dp"
android:background="@color/primary"/>
<it.neokree.materialtabs.MaterialTabHost
android:id="@+id/tab_movie_list"
android:layout_height="48dp"
android:layout_width="match_parent"
app:textColor="@color/text"
app:primaryColor="?attr/colorPrimary"
app:accentColor="@android:color/white"/>
<android.support.v4.view.ViewPager
android:id="@+id/pager_movie"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:layout_below="@id/tab_movie_list"
android:layout_marginTop="4dp"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"/>
<View
android:id="@+id/fab_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:background="@color/fab_overlay"/>
<com.getbase.floatingactionbutton.FloatingActionsMenu
android:id="@+id/fab_menu_sort"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true"
android:layout_marginBottom="16dp"
android:layout_marginRight="16dp"
android:layout_marginEnd="16dp"
fab:fab_addButtonColorNormal="@color/primary"
fab:fab_addButtonColorPressed="@color/primary_light"
fab:fab_addButtonPlusIconColor="@android:color/white"
fab:fab_labelStyle="@style/FloatingActionBarTextMenu"
>
<com.getbase.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_sort_lastest"
style="@style/FAB"
fab:fab_title="@string/fab_sort_lastest"
fab:fab_icon="@drawable/ic_vertical_align_top_white_24dp"
/>
<com.getbase.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_sort_oldest"
style="@style/FAB"
fab:fab_title="@string/fab_sort_oldest"
fab:fab_icon="@drawable/ic_vertical_align_bottom_white_24dp"
/>
<com.getbase.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_sort_bomber"
style="@style/FAB"
fab:fab_title="@string/fab_sort_bomber"
fab:fab_icon="@drawable/ic_format_line_spacing_white_24dp"
/>
</com.getbase.floatingactionbutton.FloatingActionsMenu>
</RelativeLayout>
<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.Toolbar
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPrimary"
app:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
android:fitsSystemWindows="true"
/>
view raw toolbar.xml hosted with ❤ by GitHub

And then start the activity/fragment code: (I just put the important part only!)

In MainActivity.java, we just override the onNavigationDrawerItemSelected() method when clicking the item of navigation drawer to show movie page.

In MovieListFragment, we put a SuperRecyclerView to display movie list which is a wrapper class of RecyclerView that provides some common powerful features used in list. In SuperRecyclerView, we have to add swipe-to-refresh event and scroll listener to implement endless list, and finally, add a adapter to render the movie item in RecyclerView:

public class MovieListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private List<MovieListItem> mMovieList = new ArrayList<>();
private Context mContext;
private boolean mShowBomberCount;
public MovieListAdapter(Context context, List<MovieListItem> mMovieList, boolean showBomberCount) {
this.mContext = context;
this.mMovieList = mMovieList;
this.mShowBomberCount = showBomberCount;
}
public List<MovieListItem> getMovieList() {
return mMovieList;
}
public void setMovieList(List<MovieListItem> mMovieList) {
this.mMovieList = mMovieList;
}
@Override
public int getItemViewType(int position) {
return R.layout.card_movie_list;
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
return new MovieListItemHolder(LayoutInflater.from(parent.getContext())
.inflate(viewType, parent, false), this.mShowBomberCount);
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
MovieListItemHolder holder = (MovieListItemHolder)viewHolder;
final MovieListItem movieItem = this.mMovieList.get(p);
holder.mTextMovieName.setText(movieItem.getTitleChinese());
String thumbnailPath = movieItem.getThumbnailPath();
holder.mImageMovieCover.setImageResource(R.drawable.img_empty);
if (!thumbnailPath.isEmpty())
Picasso.with(holder.mImagePoster.getContext())
.load(MainActivity.getResizePhoto(this.mContext, thumbnailPath))
.error(R.drawable.img_empty)
.into(holder.mImagePoster);
if (movieItem.getPhotoLists().size() > 0) {
String coverUrl = movieItem.getPhotoLists().get(
(int) (Math.random() * movieItem.getPhotoLists().size())).getPath();
Picasso.with(holder.mImageMovieCover.getContext())
.load(MainActivity.getResizePhoto(this.mContext, coverUrl))
.error(R.drawable.img_empty)
.into(holder.mImageMovieCover);
}
holder.mTextDuration.setText(this.mContext.getResources().getString(R.string.text_duration)
+ ": " + movieItem.getDuration());
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.TAIWAN);
holder.mTextReleaseDate.setText(this.mContext.getResources().getString(R.string.text_release_date)
+ ": " + dateFormat.format(movieItem.getReleaseDate()));
holder.mRipple.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
showMovieDetail(movieItem.getId(), movieItem.getTitleChinese());
}
});
holder.mButtonOrder.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
showMovieDetail(movieItem.getId(), movieItem.getTitleChinese());
}
});
holder.mTextGoodBomber.setText(String.valueOf(movieItem.getGoodBomber()));
holder.mTextNormalBomber.setText(String.valueOf(movieItem.getNormalBomber()));
holder.mTextBadBomber.setText(String.valueOf(movieItem.getBadBomber()));
holder.mProgressGoodBomber.setProgress((int) (movieItem.getGoodRate() * 100));
holder.mProgressNormalBomber.setProgress((int) (movieItem.getNormalRate() * 100));
holder.mProgressBadBomber.setProgress((int) (movieItem.getBadRate() * 100));
}
private void showMovieDetail(int id, String name) {
GAApplication.getTracker(mContext).send(new HitBuilders.AppViewBuilder()
.build());
Intent movieDetail = new Intent(mContext, MovieDetailActivity.class);
movieDetail.putExtra(MovieDetailActivity.EXTRA_MOVIE_ID, id);
movieDetail.putExtra(MovieDetailActivity.EXTRA_MOVIE_NAME, name);
Activity activity = (Activity) mContext;
activity.startActivity(movieDetail);
activity.overridePendingTransition(R.anim.slide_in_right, R.anim.slide_out_left);
}
@Override
public int getItemCount() {
return this.mMovieList.size();
}
class MovieListItemHolder extends RecyclerView.ViewHolder {
@InjectView(R.id.card_movie_list_item)
CardView mCardMovie;
@InjectView(R.id.ripple)
MaterialRippleLayout mRipple;
@InjectView(R.id.layout_bomber_count)
View mLayoutBomberCount;
@InjectView(R.id.image_movie_cover)
ImageView mImageMovieCover;
@InjectView(R.id.image_movie_poster)
ImageView mImagePoster;
@InjectView(R.id.text_movie_name)
TextView mTextMovieName;
@InjectView(R.id.text_release_date)
TextView mTextReleaseDate;
@InjectView(R.id.text_duration)
TextView mTextDuration;
@InjectView(R.id.text_good_bomber)
TextView mTextGoodBomber;
@InjectView(R.id.progress_good_bomber)
ArcProgress mProgressGoodBomber;
@InjectView(R.id.text_normal_bomber)
TextView mTextNormalBomber;
@InjectView(R.id.progress_normal_bomber)
ArcProgress mProgressNormalBomber;
@InjectView(R.id.text_bad_bomber)
TextView mTextBadBomber;
@InjectView(R.id.progress_bad_bomber)
ArcProgress mProgressBadBomber;
@InjectView(R.id.button_order)
Button mButtonOrder;
MovieListItemHolder(View itemView, boolean showBomberCount) {
super(itemView);
ButterKnife.inject(this, itemView);
if (showBomberCount)
this.mLayoutBomberCount.setVisibility(View.VISIBLE);
else
this.mLayoutBomberCount.setVisibility(View.GONE);
}
}
}
view raw MovieListAdapte.java hosted with ❤ by GitHub
public class MovieListFragment extends Fragment {
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private static final String ARG_TAB_POSITION = "TAB_POSITION";
@InjectView(R.id.list_movie)
SuperRecyclerView mListMovie;
private MovieListAdapter mAdapter;
private int mCurrentTab = 0;
private int mCurrentPage = 1;
private boolean mLoadingMore = false;
private boolean mShouldLoadMore = false;
public static MovieListFragment newInstance(int currentTabPosition) {
MovieListFragment fragment = new MovieListFragment();
Bundle args = new Bundle();
args.putInt(ARG_TAB_POSITION, currentTabPosition);
fragment.setArguments(args);
return fragment;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (getArguments() != null) {
this.mCurrentTab = getArguments().getInt(ARG_TAB_POSITION);
}
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_movie_list, container, false);
this.initView(rootView);
this.loadMovieList();
return rootView;
}
private void initView(View rootView) {
ButterKnife.inject(this, rootView);
this.mListMovie.getSwipeToRefresh().setColorSchemeResources(
R.color.primary,
R.color.accent,
android.R.color.holo_orange_dark);
final LinearLayoutManager layoutManager = new LinearLayoutManager(this.getActivity());
layoutManager.setOrientation(LinearLayoutManager.VERTICAL);
this.mListMovie.setLayoutManager(layoutManager);
this.mListMovie.setRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
@Override
public void onRefresh() {
if (mAdapter != null)
mAdapter.getMovieList().clear();
mCurrentPage = 1;
loadMovieList();
}
});
this.mListMovie.setOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (mShouldLoadMore && newState == RecyclerView.SCROLL_STATE_IDLE && !mLoadingMore) {
mListMovie.showMoreProgress();
loadMovieList();
}
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
int firstVisibleItem = layoutManager.findFirstVisibleItemPosition();
int visibleItemCount = layoutManager.getChildCount();
int totalItemCount = layoutManager.getItemCount();
mShouldLoadMore = (firstVisibleItem + visibleItemCount == totalItemCount);
}
});
}
private void loadMovieList() {
AsyncHttpClient httpClient = new AsyncHttpClient();
String url = formatMovieListRequest();
Logger.d(ApiTask.API_LOG_TAG, url);
httpClient.get(url, new JsonHttpResponseHandler() {
@Override
public void onSuccess(int statusCode, Header[] headers, JSONObject response) {
super.onSuccess(statusCode, headers, response);
Logger.json(ApiTask.API_LOG_TAG, response.toString());
List<MovieListItem> movieList = new ArrayList<>();
Gson gson = new Gson();
if (mAdapter == null) {
mAdapter = new MovieListAdapter(new ArrayList<MovieListItem>());
mListMovie.setAdapter(mAdapter);
}
mListMovie.getSwipeToRefresh().setRefreshing(false);
mListMovie.hideMoreProgress();
try {
JSONArray objects = response.getJSONArray(ApiTask.RESPONSE_OBJECTS);
if (objects.length() > 0) {
for (int i = 0; i < objects.length(); i++)
movieList.add(gson.fromJson(objects.getJSONObject(i).toString(), MovieListItem.class));
mAdapter.getMovieList().addAll(movieList);
} else {
if (mAdapter.getMovieList().size() <= 0)
Toast.makeText(getActivity(), getResources().getString(R.string.warn_no_movies),
Toast.LENGTH_SHORT).show();
else
Toast.makeText(getActivity(), getResources().getString(R.string.warn_no_more_movies),
Toast.LENGTH_SHORT).show();
return;
}
}
catch (JSONException e) {
this.onFailure(statusCode, headers, e, response);
}
mAdapter.notifyDataSetChanged();
mCurrentPage++;
}
@Override
public void onFailure(int statusCode, Header[] headers, Throwable throwable, JSONObject errorResponse) {
super.onFailure(statusCode, headers, throwable, errorResponse);
Logger.e((Exception)throwable);
mListMovie.getSwipeToRefresh().setRefreshing(false);
mListMovie.hideMoreProgress();
}
});
}
private String formatMovieListRequest() {
JSONObject q = new JSONObject();
try {
JSONArray filters = new JSONArray();
JSONObject filter = new JSONObject();
filter.put(Query.PARAM_NAME, Query.FIELD_RELEASE_STATUS);
filter.put(Query.PARAM_OP, Query.OPERATOR_EQUAL);
filter.put(Query.PARAM_VAL, MovieListTab.values()[this.mCurrentTab]);
filters.put(filter);
JSONArray orderBy = new JSONArray();
JSONObject dateSort = new JSONObject();
dateSort.put(Query.PARAM_FIELD, Query.FIELD_RELEASE_DATE);
dateSort.put(Query.PARAM_DIRECTION, Query.OPERATOR_DESC);
orderBy.put(dateSort);
q.put(Query.PARAM_FILTERS, filters);
q.put(Query.PARAM_ORDER_BY, orderBy);
}
catch (JSONException e) {
e.printStackTrace();
}
Resources res = this.getActivity().getResources();
try {
return String.format("%s%s%s?q=%s&%s=%d", res.getString(R.string.host),
res.getString(R.string.api_root), res.getString(R.string.api_movie_list),
URLEncoder.encode(q.toString(), "UTF8"),
Query.PARAM_PAGE, this.mCurrentPage);
}
catch (UnsupportedEncodingException e) {
return "";
}
}
}

The full code is also available on github:

https://github.com/enginebai/Movie-lol-android

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Blog at WordPress.com.

Up ↑