在Android上实现Master/Detail Flow
Author: nex3z
2016-01-09
Master/Detail Flow包含Master和Detail两个页面,Master页面显示包含了若干项目(item)的列表,给出各个项目的大概信息。当点击某个项目时,包含了该项目详细信息的Detail页面会显示出来。这种模式的一个优势在于,可以方便地适配不同的屏幕尺寸:手机屏幕尺寸有限,一般一次只能展示一个页面,那么首先显示Master,当用户点击感兴趣的项目时,再将该项目的Detail显示出来;而平板电脑的屏幕尺寸比较大,可以将Master和Detail并排显示,避免显示空间的浪费。
在Android上实现Master/Detail Flow十分方便,新版本的Android Studio已经自带了Master/Detail Flow的模板。下面通过一个例子介绍Master/Detail Flow的基本实现方法。例子从TMDb上获取一系列电影信息,在Master页面由RecyclerView以网格的形式显示出来,当点击了某一个电影之后,显示包含了该电影详细信息的Detail页面。从TMDb获取电影信息的方法可参考Retrofit使用方法举例,RecyclerView的使用方法可参考RecyclerView使用方法举例(1)和RecyclerView使用方法举例(2)。
最终实现效果如下:


1. 页面层级和布局
1.1. Detail Activity布局
首先进行Detail页面的布局,Detail页面在小屏幕上会覆盖Master页面显示,在大屏幕上会和Master页面并排显示,无论如何显示,Detail页面本身的布局是不变的。Detail Acitvity的布局层级如下:
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<android.support.design.widget.AppBarLayout
<android.support.design.widget.CollapsingToolbarLayout
android:id="@+id/detail_backdrop"
<android.support.v7.widget.Toolbar
</android.support.design.widget.CollapsingToolbarLayout>
</android.support.design.widget.AppBarLayout>
<android.support.v4.widget.NestedScrollView
android:id="@+id/movie_detail_container"
</android.support.design.widget.CoordinatorLayout>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
... >
<android.support.design.widget.AppBarLayout
... >
<android.support.design.widget.CollapsingToolbarLayout
... >
<ImageView
android:id="@+id/detail_backdrop"
... />
<android.support.v7.widget.Toolbar
... />
</android.support.design.widget.CollapsingToolbarLayout>
</android.support.design.widget.AppBarLayout>
<android.support.v4.widget.NestedScrollView
android:id="@+id/movie_detail_container"
... />
</android.support.design.widget.CoordinatorLayout>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
... >
<android.support.design.widget.AppBarLayout
... >
<android.support.design.widget.CollapsingToolbarLayout
... >
<ImageView
android:id="@+id/detail_backdrop"
... />
<android.support.v7.widget.Toolbar
... />
</android.support.design.widget.CollapsingToolbarLayout>
</android.support.design.widget.AppBarLayout>
<android.support.v4.widget.NestedScrollView
android:id="@+id/movie_detail_container"
... />
</android.support.design.widget.CoordinatorLayout>
其中movie_detail_container 用于盛放Detail Fragment。注意这里在CollapsingToolbarLayout里面放了一个ImageView,用来显示一张电影图片。
【完整代码】
1.2. Detail Fragment布局
Detail Fragment用来显示电影的详细信息,完整代码参考这里。
1.3. Master Activity布局
在Master页面中加入一个FrameLayout,其中include了movie_grid :
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<android.support.design.widget.AppBarLayout
<android.support.v7.widget.Toolbar
</android.support.design.widget.AppBarLayout>
android:id="@+id/frameLayout"
<include layout="@layout/movie_grid" />
</android.support.design.widget.CoordinatorLayout>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
... >
<android.support.design.widget.AppBarLayout
... >
<android.support.v7.widget.Toolbar
... />
</android.support.design.widget.AppBarLayout>
<FrameLayout
android:id="@+id/frameLayout"
...>
<include layout="@layout/movie_grid" />
</FrameLayout>
</android.support.design.widget.CoordinatorLayout>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
... >
<android.support.design.widget.AppBarLayout
... >
<android.support.v7.widget.Toolbar
... />
</android.support.design.widget.AppBarLayout>
<FrameLayout
android:id="@+id/frameLayout"
...>
<include layout="@layout/movie_grid" />
</FrameLayout>
</android.support.design.widget.CoordinatorLayout>
【完整代码】
上面的movie_grid 是实现Master/Detail Flow布局自动适配屏幕尺寸的关键,在/res/layout/movie_grid.xml中,布局只有MovieGridFragment,即Master页面的Fragment,此时屏幕中只显示Master页面,适用于较小的屏幕:
android:id="@+id/fragment"
android:name="com.nex3z.examples.masterdetail.ui.fragment.MovieGridFragment" />
<fragment
...
android:id="@+id/fragment"
android:name="com.nex3z.examples.masterdetail.ui.fragment.MovieGridFragment" />
<fragment
...
android:id="@+id/fragment"
android:name="com.nex3z.examples.masterdetail.ui.fragment.MovieGridFragment" />
【完整代码】
此外在res/layout/movie_grid_twopanes.xml中,给出Master和Detail并列显示的布局,movie_detail_container 用于盛放Detail Fragment,适用于较大的屏幕:
android:id="@+id/fragment"
android:name="com.nex3z.examples.masterdetail.ui.fragment.MovieGridFragment"
android:id="@+id/movie_detail_container"
<LinearLayout
... ">
<fragment
android:id="@+id/fragment"
android:name="com.nex3z.examples.masterdetail.ui.fragment.MovieGridFragment"
... />
<ScrollView
android:id="@+id/movie_detail_container"
... />
</LinearLayout>
<LinearLayout
... ">
<fragment
android:id="@+id/fragment"
android:name="com.nex3z.examples.masterdetail.ui.fragment.MovieGridFragment"
... />
<ScrollView
android:id="@+id/movie_detail_container"
... />
</LinearLayout>
【完整代码】
最后在res/values-sw600dp/refs.xml中,以别名的形式,为最小屏幕宽度为600dp的设备适配Master和Detail并列显示的布局,即movie_grid_twopanes.xml。
<item type="layout" name="movie_grid">@layout/movie_grid_twopanes</item>
<resources>
<item type="layout" name="movie_grid">@layout/movie_grid_twopanes</item>
</resources>
<resources>
<item type="layout" name="movie_grid">@layout/movie_grid_twopanes</item>
</resources>
【完整代码】
1.4. Master Fragment布局
Master Fragment包含一个RecyclerView,用于以网格的形式显示电影。
【完整代码】
2. Master/Detail的切换
2.1. 判断当前布局类型
Master/Detail模式在不同屏幕尺寸的设备上具有不同的界面布局,首先需要获取当前的布局类型,以此来决定Detail Activity的显示逻辑。
由前面Master Activity的布局可见,小屏幕和大屏幕设备布局的区别,在于movie_detail_container 的位置。小屏幕设备使用/res/layout/movie_grid.xml,其中没有movie_detail_container ,movie_detail_container 位于Detail Activity的布局里。大屏幕设备使用res/layout/movie_grid_twopanes.xml,里面有movie_detail_container ,可以直接将Detail Fragment添加进去。通过检查movie_detail_container 是否存在,可以判断当前的布局类型。在Master Activity中加入:
private boolean mTwoPane;
protected void onCreate(Bundle savedInstanceState) {
setContentView(R.layout.activity_movie_grid);
if (findViewById(R.id.movie_detail_container) != null) {
private boolean mTwoPane;
...
@Override
protected void onCreate(Bundle savedInstanceState) {
...
setContentView(R.layout.activity_movie_grid);
...
if (findViewById(R.id.movie_detail_container) != null) {
mTwoPane = true;
} else {
mTwoPane = false;
}
}
private boolean mTwoPane;
...
@Override
protected void onCreate(Bundle savedInstanceState) {
...
setContentView(R.layout.activity_movie_grid);
...
if (findViewById(R.id.movie_detail_container) != null) {
mTwoPane = true;
} else {
mTwoPane = false;
}
}
其中mTwoPane 用于指示当前布局类型。
【完整代码】
2.2. Detail页面的显示
当用户点击Master页面的电影时,将出现包含该电影详细信息的Detail页面。根据当前布局类型的不同,选择显示Detail Activity覆盖Master Activity(小尺寸屏幕),或者直接将Detail Fragment添加到Master页面旁边(大尺寸屏幕)。
2.1.1. 为RecyclerView Adapter添加OnClickListener
首先要让RecyclerView能够支持点击事件,在RecyclerView Adapter中加入:
public class MovieAdapter extends RecyclerView.Adapter<MovieAdapter.ViewHolder> {
private static OnItemClickListener mListener;
public interface OnItemClickListener {
void onItemClick(int position, MovieAdapter.ViewHolder vh);
public void setOnItemClickListener(OnItemClickListener listener) {
this.mListener = listener;
public static class ViewHolder extends RecyclerView.ViewHolder {
public ViewHolder(View itemView) {
itemView.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
mListener.onItemClick(getLayoutPosition(), ViewHolder.this);
public class MovieAdapter extends RecyclerView.Adapter<MovieAdapter.ViewHolder> {
private static OnItemClickListener mListener;
public interface OnItemClickListener {
void onItemClick(int position, MovieAdapter.ViewHolder vh);
}
public void setOnItemClickListener(OnItemClickListener listener) {
this.mListener = listener;
}
...
public static class ViewHolder extends RecyclerView.ViewHolder {
...
public ViewHolder(View itemView) {
super(itemView);
...
itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (mListener != null) {
mListener.onItemClick(getLayoutPosition(), ViewHolder.this);
}
}
});
}
}
}
public class MovieAdapter extends RecyclerView.Adapter<MovieAdapter.ViewHolder> {
private static OnItemClickListener mListener;
public interface OnItemClickListener {
void onItemClick(int position, MovieAdapter.ViewHolder vh);
}
public void setOnItemClickListener(OnItemClickListener listener) {
this.mListener = listener;
}
...
public static class ViewHolder extends RecyclerView.ViewHolder {
...
public ViewHolder(View itemView) {
super(itemView);
...
itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (mListener != null) {
mListener.onItemClick(getLayoutPosition(), ViewHolder.this);
}
}
});
}
}
}
之后就可以使用setOnItemClickListener() 来添加点击事件监听。
【完整代码】
2.2.2. 传递点击事件到Master Activity
显示电影列表的RecyclerView位图Master Fragment,当点击事件发生时,Master Fragment将此事件传递给Master Activity,由Master Activity根据当前布局类型mTwoPane 决定应当如何显示Detail页面。
在Master Fragment中添加接口用于回调,Master Activity应实现该接口,然后在onAttach() 的时候从Master Activity中获取回调方法:
private Callbacks mCallbacks = sDummyCallbacks;
public interface Callbacks {
void onItemSelected(Movie movie, MovieAdapter.ViewHolder vh);
public void onAttach(Context context) {
if (!(context instanceof Callbacks)) {
throw new IllegalStateException("Activity must implement fragment's callbacks.");
mCallbacks = (Callbacks) context;
private Callbacks mCallbacks = sDummyCallbacks;
public interface Callbacks {
void onItemSelected(Movie movie, MovieAdapter.ViewHolder vh);
}
...
@Override
public void onAttach(Context context) {
super.onAttach(context);
if (!(context instanceof Callbacks)) {
throw new IllegalStateException("Activity must implement fragment's callbacks.");
}
mCallbacks = (Callbacks) context;
}
private Callbacks mCallbacks = sDummyCallbacks;
public interface Callbacks {
void onItemSelected(Movie movie, MovieAdapter.ViewHolder vh);
}
...
@Override
public void onAttach(Context context) {
super.onAttach(context);
if (!(context instanceof Callbacks)) {
throw new IllegalStateException("Activity must implement fragment's callbacks.");
}
mCallbacks = (Callbacks) context;
}
在点击事件发生时,调用该回调方法:
mMovieAdapter = new MovieAdapter(mMovies);
mMovieAdapter.setOnItemClickListener(new MovieAdapter.OnItemClickListener() {
public void onItemClick(int position, MovieAdapter.ViewHolder vh) {
Log.v(LOG_TAG, "onItemClick(): position = " + position);
Movie movie = mMovies.get(position);
mCallbacks.onItemSelected(movie, vh);
mMovieAdapter = new MovieAdapter(mMovies);
mMovieAdapter.setOnItemClickListener(new MovieAdapter.OnItemClickListener() {
@Override
public void onItemClick(int position, MovieAdapter.ViewHolder vh) {
Log.v(LOG_TAG, "onItemClick(): position = " + position);
Movie movie = mMovies.get(position);
if (movie != null) {
mCallbacks.onItemSelected(movie, vh);
}
mPosition = position;
}
});
mMovieAdapter = new MovieAdapter(mMovies);
mMovieAdapter.setOnItemClickListener(new MovieAdapter.OnItemClickListener() {
@Override
public void onItemClick(int position, MovieAdapter.ViewHolder vh) {
Log.v(LOG_TAG, "onItemClick(): position = " + position);
Movie movie = mMovies.get(position);
if (movie != null) {
mCallbacks.onItemSelected(movie, vh);
}
mPosition = position;
}
});
【完整代码】
2.2.3. 在Master Activity处理点击事件并显示Detail页面
Master Activity实现Master Fragment定义的回调接口,当用户点击RecyclerView中的电影时,Master Fragment通过回调方法通知Master Activity。
public class MovieGridActivity extends AppCompatActivity implements MovieGridFragment.Callbacks{
public void onItemSelected(Movie movie, MovieAdapter.ViewHolder vh) {
Log.v(LOG_TAG, "onItemSelected(): movie = " + movie);
MovieDetailFragment fragment = MovieDetailFragment.newInstance(movie);
getSupportFragmentManager().beginTransaction()
.replace(R.id.movie_detail_container, fragment)
Intent intent = new Intent(this, MovieDetailActivity.class)
.putExtra(MovieDetailActivity.MOVIE_INFO, movie);
ActivityOptionsCompat activityOptions = ActivityOptionsCompat
.makeSceneTransitionAnimation(this, new Pair<View, String>(
getString(R.string.detail_poster_transition_name)));
ActivityCompat.startActivity(this, intent, activityOptions.toBundle());
public class MovieGridActivity extends AppCompatActivity implements MovieGridFragment.Callbacks{
...
@Override
public void onItemSelected(Movie movie, MovieAdapter.ViewHolder vh) {
Log.v(LOG_TAG, "onItemSelected(): movie = " + movie);
if (mTwoPane) {
MovieDetailFragment fragment = MovieDetailFragment.newInstance(movie);
getSupportFragmentManager().beginTransaction()
.replace(R.id.movie_detail_container, fragment)
.commit();
} else {
Intent intent = new Intent(this, MovieDetailActivity.class)
.putExtra(MovieDetailActivity.MOVIE_INFO, movie);
ActivityOptionsCompat activityOptions = ActivityOptionsCompat
.makeSceneTransitionAnimation(this, new Pair<View, String>(
vh.mPoster,
getString(R.string.detail_poster_transition_name)));
ActivityCompat.startActivity(this, intent, activityOptions.toBundle());
}
}
}
public class MovieGridActivity extends AppCompatActivity implements MovieGridFragment.Callbacks{
...
@Override
public void onItemSelected(Movie movie, MovieAdapter.ViewHolder vh) {
Log.v(LOG_TAG, "onItemSelected(): movie = " + movie);
if (mTwoPane) {
MovieDetailFragment fragment = MovieDetailFragment.newInstance(movie);
getSupportFragmentManager().beginTransaction()
.replace(R.id.movie_detail_container, fragment)
.commit();
} else {
Intent intent = new Intent(this, MovieDetailActivity.class)
.putExtra(MovieDetailActivity.MOVIE_INFO, movie);
ActivityOptionsCompat activityOptions = ActivityOptionsCompat
.makeSceneTransitionAnimation(this, new Pair<View, String>(
vh.mPoster,
getString(R.string.detail_poster_transition_name)));
ActivityCompat.startActivity(this, intent, activityOptions.toBundle());
}
}
}
当mTwoPane 为true时,当前为大屏的双栏模式,直接将Detail Fragment添加到movie_detail_container 里面;当mTwoPane 为false时,当前为小屏,显示Detail Activity。
【完整代码】
3. 完整代码
完整代码可以参考这里。
Thank you very much for the tutorial
can you do more tutorial in changing the sorting data from SORT_BY_POPULARITY_DESC to SORT_BY_VOTE_AVERAGE_DESC by using button or popup menu?