标签导航

标签导航是常见的几种APP导航模式之一. 我们会经常用到这种模式. 使用标签导航的APP比较著名的有微信, 如下图所示:

通常的做法是在底部写几个RadioButton, 或者是ImageView加TextView来模拟单选. 然后在Activity中使用OnClickListener来控制每个标签的状态, 同时还需要控制每个Fragment的创建与销毁, 显示与隐藏. 代码量不少, 如果每个项目都这么写会浪费很多精力与成本. 因此我们需要一个控件, 来代替OnClickListener去维护标签的状态, 以及Fragment的状态. 我们需要做的只是告诉这个控件, 每个标签长什么样, 标签对应的Fragment的类是什么, 需不需要参数等.

需求

下面我们试着实现以下UI效果:

上述效果图在严格意义上来说不算是标签导航, 但是从开发来看, 实现的方式类似. 以下是具体需求:

有四个标签, 分别是心理测试, 心情圈, 健步走以及个人中心, 每个标签分别在当前页面内切换标签页. 点击中间的加号按钮则需要跳转到另一个页面, 当前页面则继续显示之前的标签页.

首先我们来分析一下重点:

  1. 中间加号的UI效果实现

  2. 点击加号跳转新的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));
    }
}

Last updated