标签导航是常见的几种APP导航模式之一. 我们会经常用到这种模式. 使用标签导航的APP比较著名的有微信, 如下图所示:
通常的做法是在底部写几个RadioButton, 或者是ImageView加TextView来模拟单选. 然后在Activity中使用OnClickListener来控制每个标签的状态, 同时还需要控制每个Fragment的创建与销毁, 显示与隐藏. 代码量不少, 如果每个项目都这么写会浪费很多精力与成本. 因此我们需要一个控件, 来代替OnClickListener去维护标签的状态, 以及Fragment的状态. 我们需要做的只是告诉这个控件, 每个标签长什么样, 标签对应的Fragment的类是什么, 需不需要参数等.
需求
下面我们试着实现以下UI效果:
上述效果图在严格意义上来说不算是标签导航, 但是从开发来看, 实现的方式类似. 以下是具体需求:
有四个标签, 分别是心理测试, 心情圈, 健步走以及个人中心, 每个标签分别在当前页面内切换标签页. 点击中间的加号按钮则需要跳转到另一个页面, 当前页面则继续显示之前的标签页.
首先我们来分析一下重点:
点击加号跳转新的Activity, 并保持当前的标签页
如果没有中间的加号, 使用FragmentTabHost就能实现, 具体用法可以参考Android常用控件之FragmentTabHost的使用. 但是有了中间的加号FragmentTabHost就力不从心了, 因为FragmentTabHost的每一个标签都要对应一个Fragment, 不能对应Activity, 也无法对点击事件做拦截. 通过查看源码发现OnTabChangeListener也是在标签切换完成之后才会回调. 因此FragmentTabHost无法完成上述需求. 怎么办呢? 引入InterceptedFragmentTabHost.
InterceptedFragmentTabHost
InterceptedFragmentTabHost是CoreLibs中对FragmentTabHost的扩展, 实现了tab切换拦截功能. 一旦多了拦截功能, 就能实现上述需求了. 比如FragmentTabHost中实际上是有5个标签, 包含了中间的加号. 我可以通过设置拦截监听器, 检测一旦用户点击了第三个标签, 就不进行切换操作, 而是跳转至一个新的Activity.
由于通过继承FragmentTabHost没法很好的实现拦截功能, 并且FragmentTabHost内部只是引用了几个Android内部的id资源, 没有其他内部资源, 因此我们完全可以将FragmentTabHost的源码复制出来放到自己的项目中, 而不会出现编译不通过的后果. InterceptedFragmentTabHost就是通过这种方式对FragmentTabHost扩展.
接下来看看如何使用InterceptedFragmentTabHost. InterceptedFragmentTabHost中提供了如下方法来设置拦截监听器:
public void setTabChangeInterceptor(TabChangeInterceptor interceptor);
以下是TabChangeInterceptor接口的代码:
/**
* 用于{@link InterceptedFragmentTabHost}权限控制
*/
public interface TabChangeInterceptor {
/**
* 是否能切换到标签为tabId的Fragment
* @param tabId 目标Fragment的标签
* @return 是否能切换
*/
boolean canTab(String tabId);
/**
* 当切换被拦截时调用
* @param tabId 被拦截的Fragment的标签
*/
void onTabIntercepted(String tabId);
}
InterceptedFragmentTabHost的用法与FragmentTabHost基本完全一致, 唯一不同的就是多了setTabChangeInterceptor方法. 由于FragmentTabHost需要我们提供每个标签对应的tab, 因此我们可以声明一个String数组:
String[] tabTags = new String[] { getString(R.string.tab_test),
getString(R.string.tab_mood), getString(R.string.tab_add_mood),
getString(R.string.tab_pm), getString(R.string.tab_me) };
有了tabTags, 我们就可以跟据TabChangeInterceptor中传来的tabId来判断用户点击的是哪个标签了:
interceptedFragmentTabHost.setTabChangeInterceptor(new TabChangeInterceptor() {
@Override
public boolean canTab(String tabId) {
return !tabId.equals(tabTags[2]); // 判断点击的是否是第三个标签
}
@Override
public void onTabIntercepted(String tabId) {
toAddMood(); // 跳转Activity
}
});
TabChangeInterceptor的用法很简单, 每当用户点击一个非当前标签的标签时, InterceptedFragmentTabHost都会根据canTab方法的返回值来判断是否能做切换操作. 如果返回true, 意味着能切换, 返回false则意味不能切换. !tabId.equals(tabTags[2])
这行代码意味着只要点击的不是第三个标签, canTab都会返回true, 如果是第三个, 则返回false. 一旦canTab返回false, InterceptedFragmentTabHost会紧接着调用onTabIntercepted方法, 我们可以在此方法中做一些其他的事情, 如跳转Activity.
TabNavigator
使用FragmentTabHost虽然不需要控制点击事件以及Fragment的切换, 但是还是需要做一些Tab页设置等工作. 这些逻辑也可以抽出一个公共类 - TabNavigator. TabNavigator只需实现一个接口, 加上一行代码, 就可以配置好FragmentTabHost:
/**
* Fragment tab页切换需实现此接口, 来获取tab页的必要信息
*/
public interface TabNavigatorContent {
/**
* 根据position获取每个tab标签视图
* @param position tab标签位置
* @return tab标签视图
*/
View getTabView(int position);
/**
* 根据position获取切换至目标Fragment要传递的数据Bundle
* @param position 目标Fragment位置
* @return 数据Bundle
*/
Bundle getArgs(int position);
/**
* 获取Fragment的类对象数组
* @return Fragment的类对象数组
*/
Class[] getFragmentClasses();
/**
* 获取每个Fragment的tag
* @return Fragment的tag数组
*/
String[] getTabTags();
}
public void setup(Context context, InterceptedFragmentTabHost tabHost, TabNavigatorContent content,
FragmentManager manager, int containerId)
只要我们的Activity实现了TabNavigatorContent接口, 就可以通过下面的代码来配置FragmentTabHost:
private TabNavigator navigator = new TabNavigator();
navigator.setup(context, tabHost, content, getSupportFragmentManager(), R.id.real_tab_content);
实现
下面我们来看看具体如何使用TabNavigator加InterceptedFragmentTabHost来实现前面提到的UI效果图.
Activity布局
首先标签导航一般是位于主页, 因此我们来看看MainActivity的布局:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/real_tab_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="@dimen/tab_content_height" />
<com.corelibs.views.tab.InterceptedFragmentTabHost
android:id="@android:id/tabhost"
android:layout_width="match_parent"
android:layout_alignParentBottom="true"
android:layout_height="@dimen/tab_height" />
</RelativeLayout>
代码中只有两个控件, 一个FrameLayout, 以及一个InterceptedFragmentTabHost, 两个控件被RelativeLayout包裹. 接着看看dimen里的度量单位:
<dimen name="tab_height">75dp</dimen>
<dimen name="tab_content_height">55dp</dimen>
为什么是75和55dp呢? 可以看看使用钛合金狗眼测量的图:
InterceptedFragmentTabHost的高度就是整个底部标签栏的高度, tab_content_height则是每个标签内容的具体高度. 两个高度不一致是由于加号按钮比标签要高. 又因为Fragment里的内容需要显示在标签内容上, 加号按钮下, 所以才使用RelativeLayout包裹, 并且FrameLayout的layout_marginBottom为tab_content_height. FrameLayout就是承载Fragment的容器.
标签布局
除了加号按钮, 其他四个布局都类似, 因此我们可以使用同一个布局:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="@dimen/tab_height"
android:gravity="bottom">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="@dimen/tab_content_height"
android:orientation="vertical"
android:gravity="center"
android:background="@color/tab_bg">
<ImageView
android:id="@+id/iv_tab_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/tab_test_icon"
android:contentDescription="@null"
android:layout_marginBottom="3dp"/>
<TextView
android:id="@+id/tv_tab_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@drawable/tab_text_color"
android:text="@string/tab_test"/>
</LinearLayout>
</LinearLayout>
整个根布局实际上也是75dp, 只是实际上的标签内容是55dp, 并且显示在下方. 效果图:
接着来画加号按钮:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="@dimen/tab_height">
<View
android:layout_width="match_parent"
android:layout_height="@dimen/tab_content_height"
android:background="@color/tab_bg"
android:layout_alignParentBottom="true"/>
<ImageView
android:layout_width="@dimen/tab_height"
android:layout_height="@dimen/tab_height"
android:src="@mipmap/tab_add_mood"
android:contentDescription="@null"
android:padding="6dp"
android:background="@drawable/tab_add_bg"
android:layout_centerInParent="true"/>
</RelativeLayout>
与标签布局类似, 根布局的高度也是75dp, 底部有个55dp的view, 背景色与标签布局的背景色一样, 这样绿色背景就能无缝连接起来. ImageView则是居中显示, 宽高都是75dp, 但是留有6dp的padding, 用于显示绿色的圆环. 圆环则通过drawable背景来实现:
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/tab_bg" />
</shape>
Java代码
接下来看看MainActivity的代码. 有了布局, 我们还需要一个包含标签tag的String数组, 一个包含Fragment的Class的数组, 以及标签内icon的资源id的int数组:
String[] tabTags = new String[] { getString(R.string.tab_test),
getString(R.string.tab_mood), getString(R.string.tab_add_mood),
getString(R.string.tab_pm), getString(R.string.tab_me) };
Class[] classes = new Class[] { TestFragment.class,
MoodFragment.class, AddMoodFragment.class,
PedometerFragment.class, MeFragment.class }
int[] imageResIds = new int[] { R.drawable.tab_test_icon, R.drawable.tab_mood_icon, 0,
R.drawable.tab_pm_icon, R.drawable.tab_me_icon };
每个imageResId都是使用的Selector. 因为每个标签被选中之后, 所有的View都会被设置成selected状态:
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true" android:drawable="@mipmap/tab_test_selected"/>
<item android:drawable="@mipmap/tab_test"/>
</selector>
有了上述这些信息, 我们就可以去实现TabNavigator的TabNavigatorContent接口了:
public static final int ADD_MOOD_POSITION = 2;
@Override
public View getTabView(int position) {
View view;
if (position == ADD_MOOD_POSITION) {
// 加号按钮布局
view = getLayoutInflater().inflate(R.layout.view_tab_add, null);
return view;
} else {
// 普通标签布局
view = getLayoutInflater().inflate(R.layout.view_tab_content, null);
}
ImageView iv = (ImageView) view.findViewById(R.id.iv_tab_icon);
TextView tv = (TextView) view.findViewById(R.id.tv_tab_text);
iv.setImageResource(imageResIds[position]); // 设置每个标签的icon
tv.setText(tabTags[position]); // 设置每个标签的文字
return view;
}
@Override
public Bundle getArgs(int position) {
return null; // 因为每个Fragment切换无需传递数据, 因此此处为null
}
@Override
public Class[] getFragmentClasses() {
return classes;
}
@Override
public String[] getTabTags() {
return tabTags;
}
接着看看与TabNavigator与InterceptedFragmentTabHost有关的代码. InterceptedFragmentTabHost我们通过ButterKnife bind出来, 而TabNavigator我们可以直接new出来:
@Bind(android.R.id.tabhost) InterceptedFragmentTabHost tabHost;
private TabNavigator navigator = new TabNavigator();
protected void init(Bundle savedInstanceState) {
navigator.setup(this, tabHost, this, getSupportFragmentManager(), R.id.real_tab_content);
navigator.setTabChangeInterceptor(new TabChangeInterceptor() {
@Override
public boolean canTab(String tabId) {
return !tabId.equals(tabTags[ADD_MOOD_POSITION]);
}
@Override
public void onTabIntercepted(String tabId) {
toAddMood();
}
});
}
由于MainActivity实现了TabNavigatorContent接口, 所以TabNavigator的setup方法的第三个参数传来this. 到此, 整个标签导航就完成了. 至于每个标签页的具体内容, 则分别交给TestFragment.class, MoodFragment.class, AddMoodFragment.class, PedometerFragment.class, MeFragment.class这几个类去处理. 此处还需要注意的是, 如果一旦tabTags, classes等几个数组的长度不一样, TabNavigator则会根据classes的长度来生成标签.
看看完整的MainActivity代码:
public class MainActivity extends BaseActivity implements TabNavigator.TabNavigatorContent {
public static final int ADD_MOOD_POSITION = 2;
@Bind(android.R.id.tabhost) InterceptedFragmentTabHost tabHost;
private TabNavigator navigator = new TabNavigator();
private String[] tabTags;
private int[] imageResIds = new int[] { R.drawable.tab_test_icon, R.drawable.tab_mood_icon, 0,
R.drawable.tab_pm_icon, R.drawable.tab_me_icon };
public static Intent getLaunchIntent(Context context) {
return new Intent(context, MainActivity.class);
}
@Override
protected int getLayoutId() {
return R.layout.activity_main;
}
@Override
protected void init(Bundle savedInstanceState) {
tabTags = new String[] { getString(R.string.tab_test),
getString(R.string.tab_mood), getString(R.string.tab_add_mood),
getString(R.string.tab_pm), getString(R.string.tab_me) };
navigator.setup(this, tabHost, this, getSupportFragmentManager(), R.id.real_tab_content);
navigator.setTabChangeInterceptor(new TabChangeInterceptor() {
@Override
public boolean canTab(String tabId) {
return !tabId.equals(tabTags[ADD_MOOD_POSITION]);
}
@Override
public void onTabIntercepted(String tabId) {
toAddMood();
}
});
}
@Override
protected BasePresenter createPresenter() {
return null;
}
@Override
public View getTabView(int position) {
View view;
if (position == ADD_MOOD_POSITION) {
view = getLayoutInflater().inflate(R.layout.view_tab_add, null);
return view;
} else {
view = getLayoutInflater().inflate(R.layout.view_tab_content, null);
}
ImageView iv = (ImageView) view.findViewById(R.id.iv_tab_icon);
TextView tv = (TextView) view.findViewById(R.id.tv_tab_text);
iv.setImageResource(imageResIds[position]);
tv.setText(tabTags[position]);
return view;
}
@Override
public Bundle getArgs(int position) {
return null;
}
@Override
public Class[] getFragmentClasses() {
return new Class[] { TestFragment.class,
MoodFragment.class, AddMoodFragment.class,
PedometerFragment.class, MeFragment.class };
}
@Override
public String[] getTabTags() {
return tabTags;
}
private void toAddMood() {
startActivity(AddMoodActivity.getLaunchIntent(MainActivity.this));
}
}
最终效果:
关键代码
InterceptedFragmentTabHost
@Override
public void onTabChanged(String tabId) {
if (mAttached) {
// 判断canTab的返回值
if (mInterceptor != null && !mInterceptor.canTab(tabId)) {
// 拦截
// 将CurrentTab重置成上一次的值
setCurrentTabByTag(mLastTab.tag);
mInterceptor.onTabIntercepted(tabId);
} else {
// 切换
FragmentTransaction ft = doTabChanged(tabId, null);
if (ft != null) {
ft.commitAllowingStateLoss();
}
if (mOnTabChangeListener != null) {
mOnTabChangeListener.onTabChanged(tabId);
}
}
}
}
TabNavigator
private TabNavigatorContent content;
private InterceptedFragmentTabHost host;
private void init() {
Class[] fragmentClasses = content.getFragmentClasses();
String[] tabTags = content.getTabTags();
int tabCount = fragmentClasses.length;
for (int i = 0; i < tabCount; i++) {
host.addTab(host.newTabSpec(tabTags[i]).setIndicator(content.getTabView(i)),
fragmentClasses[i], content.getArgs(i));
}
}