【原创】万能的导航条,可定制你的需求(一)

一、先看效果

QuickTabLayout.gif

二、需求

看上图我们可以看到九种组合,要实现这种,首先看Tab的样式:
1. 均分手机屏幕;
2. wrap_content,也就是等于title的长度;
3. 自定义宽度,也就是设定一个值;
4. 可以是一个图标(目前没有实现,但是这种需求基本没有)。
再观察indicator的样式:
1. 等于Tab的宽度;
2. 等于title的宽度;
3. 自定义宽度,也就是设定一个值。
有了这些mode,可以写两个枚举类:

public enum TabMode {
        /**
         * 等分的
         */
        EQUANT,
        /**
         * 适应的
         */
        WRAPCONTENT,
        /**
         * 相等的  设定值
         */
        EQUAL
    }

    public enum IndicatorMode {
        /**
         * 和tab等宽的
         */
        EQUAL_TAB,
        /**
         * 和内容等宽的
         */
        EQUAL_CONTENT,
        /**
         * 设定值
         */
        EQUAL_VALUE
    }

三、代码分析

3.1 布局

<?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="match_parent">
    <com.taovo.rjp.quicktablayout.MyHorizontalScrollView
        android:id="@+id/horizontal_scroll_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:scrollbars="none"
        >
        <LinearLayout
            android:id="@+id/ll_tab_container"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:orientation="horizontal"
            >

        </LinearLayout>
    </com.taovo.rjp.quicktablayout.MyHorizontalScrollView>

    <View
        android:id="@+id/indicator"
        android:layout_width="1px"
        android:layout_height="1px"
        android:background="#f00"
        />
</LinearLayout>

我们需要用一个自定义的HorizontalScrollView,为什么?因为我们要监听滑动事件,但是HorizontalScrollView没有提供,需要自己去实现,这个代码很多,不细说。然后里面包裹一个LinearLayout作为Tab的容器,下面一个1px * 1px的View就是我们的indicator,为什么要1px的长宽?因为实际操作中发现写wrap_content,后来会造成indicator在界面无法显示的问题。

3.2 自定义属性

<declare-styleable name="QuickTabLayout">
        <attr name="tabHeight" format="dimension" />
        <attr name="indicatorHeight" format="dimension" />
        <attr name="indicatorWidth" format="dimension" />
        <attr name="tabWidth" format="dimension" />
        <attr name="txtSelectedColor" format="color" />
        <attr name="txtUnselectedColor" format="color" />
        <attr name="indicatorColor" format="color" />
        <attr name="txtSize" format="integer" />
    </declare-styleable>

可以按照需求增加,我只添加了经常需要设置的,比如Tab的高度和宽度,indicator的高度和宽度,选中和未选中的颜色,indicator的颜色,tab文字的大小,还有需要的可以告诉我。

3.3 定义Tab

public class Tab {
    private String title;
    private TextView textView;

    private int tabLeft;
    private int tabWidth;

    private int indicatorLeft;
    private int indicatorWidth;
}

记录title,为什么要记录textView呢?可能你会疑问,这是为了省事,因为我发现除了记录tab需要一个list,记录textView还需要一个list,这样tab和textView就绑定了,只需要遍历一个list。还需要记录tab的宽度和距离父布局的left,同时indicator的宽度和距离父布局的left也需要记录。一开始肯定想不到记录的这么全,所以这个类是慢慢补全的。

3.4 初始化Tab

/**
     * 首先初始化所有的tab
     *
     * @param tabs
     * @return
     */
    private void initTabs(List<Tab> tabs) {
        int size = tabs.size();
        Paint paint = new Paint();
        for (int i = 0; i < size; i++) {
            Tab tab = tabs.get(i);
            TextView textView = new TextView(mContext);
            LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, tabHeight);
            textView.setLayoutParams(params);
            textView.setGravity(Gravity.CENTER);
            textView.setTextSize(txtSize);
            textView.setTextColor(txtUnselectedColor);
            textView.setText(tab.getTitle());
            textView.setTag(i);
            textView.setOnClickListener(new OnClickListener() {
                @Override
                public void onClick(View v) {
                    Tab preTab = mTabs.get(selectedIndex);
                    TextView preTextView = preTab.getTextView();
                    preTextView.setTextColor(txtUnselectedColor);
                    selectedIndex = (Integer) v.getTag();
                    setSelectState();
                    indicatorAnim(mTabs.get(selectedIndex), preTab);
                }
            });
            switch (indicatorMode) {
                case EQUAL_TAB:
                case EQUAL_CONTENT:
                    paint.setTextSize(textView.getTextSize());
                    float titleWidth = paint.measureText(textView.getText().toString());
                    tab.setIndicatorWidth((int) titleWidth);
                    break;
                case EQUAL_VALUE:
                    tab.setIndicatorWidth(indicatorWidth);
                    break;
            }

            tab.setTextView(textView);
        }
    }

很常见的写法,但是下面需要根据indicator的样式去设置indicator的宽度 。这里很重要。

3.5 添加Tab到容器里

/**
     * 添加Tab进容器
     */
    private void addTabInContainer() {
        int tabCount = mTabs.size();
        int textViewWidth = 0;
        switch (tabMode) {
            case EQUANT: //等分的情况下,过长或者tab过多都不去变换mode,有可能造成死循环
                textViewWidth = screenWidth / tabCount;
                for (int i = 0; i < tabCount; i++) {
                    Tab tab = mTabs.get(i);
                    tab.setTabWidth(textViewWidth);
                    llTabContainer.addView(tab.getTextView());
                    tab.setTabLeft(i * textViewWidth);
                    tab.setIndicatorLeft(i * textViewWidth + ((textViewWidth - tab.getIndicatorWidth()) / 2));
                }
                break;
            case WRAPCONTENT:
                int tabLeft = 0;
                for (int i = 0; i < tabCount; i++) {
                    Tab tab = mTabs.get(i);
                    float titleWidth = tab.getIndicatorWidth();
                    textViewWidth = (int) (titleWidth + dp2px(mContext, 20));
                    tab.setTabWidth(textViewWidth);
                    llTabContainer.addView(tab.getTextView());
                    tab.setTabLeft(tabLeft);
                    tab.setIndicatorLeft((tabLeft + ((textViewWidth - tab.getIndicatorWidth()) / 2)));
                    tabLeft += textViewWidth;
                }
                if (checkTabTotalWidth()) {
                    setTabs(mTabs);
                    return;
                }
                break;
            case EQUAL:
                textViewWidth = tabWidth;
                for (int i = 0; i < tabCount; i++) {
                    Tab tab = mTabs.get(i);
                    tab.setTabWidth(textViewWidth);
                    llTabContainer.addView(tab.getTextView());
                    tab.setTabLeft(i * textViewWidth);
                    tab.setIndicatorLeft((i * textViewWidth + ((textViewWidth - tab.getIndicatorWidth()) / 2)));
                }
                if (checkTabTotalWidth()) {
                    setTabs(mTabs);
                    return;
                }
                break;
        }
    }

这一步是重中之重,在添加的时候我们不但需要计算Tab的宽度和Tab距离父布局left的距离,同时还要计算indicator的宽度和距父布局left的距离。所以这里需要根据TabMode去差异化计算:

  1. EQUANT下,TabWidth是screenWidth除以tabCount,TabLeft就等于 i * TabWidth,IndicatorLeft等于TabLeft加上(TabWidth- IndicatorWidth) / 2),这里需要思考下为什么?画个图就很清楚了;
  2. WRAPCONTENT下,TabWidth我们默认是内容的宽度再加上左右padding,这个padding这里是写死的10dp,可以改成动态的。TabLeft的话需要手动计算,后一个的TabLeft是前一个TabLeft加上前一个TabWidth,IndicatorLeft仍然是TabLeft加上(TabWidth- IndicatorWidth) / 2);
  3. EQUAL下, tabWidth是设定的,一开始传递的,没有设置默认80dp,tabLeft等于 i * TabWidth,IndicatorLeft仍然是TabLeft加上(TabWidth- IndicatorWidth) / 2)。
    可能有人注意到WRAPCONTENT和EQUAL下最后还有checkTabTotalWidth()方法,这个方法什么时候触发呢?
/**
     * 检查总长度
     *
     * @return
     */
    private boolean checkTabTotalWidth() {
        int totalWidth = 0;
        for (Tab tab : mTabs) {
            totalWidth += tab.getTabWidth();
        }
        if (totalWidth < screenWidth) {
            tabMode = EQUANT;
            return true;
        }
        return false;
    }

当总长度小于screenWidth的时候,就是说,tabMode是WRAPCONTENT或者EQUAL的时候,所有tab加起来总长度还不够一屏幕,这个时候将转换tabMode为EQUANT,均分屏幕的长度,充满屏幕,更加美观。这是一个小技巧。当然返回true之后重新setTabs(mTabs)。

3.6 初始化选中状态

/**
     * 选中tab的一系列操作
     */
    private void setSelectState() {
        Tab tab = mTabs.get(selectedIndex);
        TextView selectedTextView = tab.getTextView();
        selectedTextView.setTextColor(txtSelectedColor);
        LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) indicatorView.getLayoutParams();
        switch (indicatorMode) {
            case EQUAL_TAB:
                layoutParams.width = tab.getTabWidth();
                layoutParams.height = indicatorHeight;
                layoutParams.setMargins(tab.getTabLeft(), 0, 0, 0);
                break;
            case EQUAL_CONTENT:
                layoutParams.width = tab.getIndicatorWidth();
                layoutParams.height = indicatorHeight;
                layoutParams.setMargins(tab.getIndicatorLeft(), 0, 0, 0);
                break;
            case EQUAL_VALUE:
                if (indicatorWidth > tab.getTabWidth()) {
                    indicatorWidth = tab.getTabWidth();
                }
                layoutParams.width = indicatorWidth;
                layoutParams.height = indicatorHeight;
                layoutParams.setMargins(tab.getIndicatorLeft(), 0, 0, 0);
                break;
        }
        indicatorView.setLayoutParams(layoutParams);
    }

我们有一个selectedIndex记录选中的下标,所以这个方法可以初始化的时候调用,在点击Tab的时候仍然可以调用,更改Tab的状态和indicator的状态。根据indicatorMode设置:

  1. EQUAL_TAB下,indicator的宽度等于TabWidth,高度布局文件里指定默认2dp,然后是位置,通过MarginLeft设置,left等于TabLeft;
  2. EQUAL_CONTENT下,indicator的宽度等于文字宽度,记录在Tab里,MarginLeft记录在indicatorLeft;
  3. EQUAL_VALUE下,需要有一个判断,如果用户设置的太长,让indicatorWidth等于TabWidth,其他同上。

3.7 监听滑动

horizontalScrollView.setOnHorizontalScrollListener(new MyHorizontalScrollView.OnHorizontalScrollListener() {
            @Override
            public void onScroll(int scrollX, int scrollY) {
                LinearLayout.LayoutParams layoutParams = (LayoutParams) indicatorView.getLayoutParams();
                Tab tab = mTabs.get(selectedIndex);
                switch (indicatorMode) {
                    case EQUAL_TAB:
                        layoutParams.setMargins(tab.getTabLeft() - scrollX, 0, 0, 0);
                        break;
                    case EQUAL_CONTENT:
                    case EQUAL_VALUE:
                        layoutParams.setMargins(tab.getIndicatorLeft() - scrollX, 0, 0, 0);
                        break;
                }
                indicatorView.setLayoutParams(layoutParams);
            }
        });

滑动的时候Tab是随着滑动的,但是indicator不是,所以需要手动设置indicator的MarginLeft,也是根据indicatorMode去判断滑动多少距离:

  1. EQUAL_TAB下,滑动tabLeft减去horizontalScrollView的scrollX,为什么减去,可以看第一个Tab的时候,假设滑出去第一个Tab的距离,那么第一个Tab的indicator应该距左-TabWidth,第一个Tab的TabLeft等于0,这个时候scrollX等于TabWidth,所以是0减去TabWidth,通用的也就是tab.getTabLeft() - scrollX;
  2. EQUAL_CONTENT和EQUAL_VALUE下,道理和上面的类似,不细说了,总结是tab.getIndicatorLeft() - scrollX。

3.8 点击Tab的时候操作

textView.setOnClickListener(new OnClickListener() {
                @Override
                public void onClick(View v) {
                    Tab preTab = mTabs.get(selectedIndex);
                    TextView preTextView = preTab.getTextView();
                    preTextView.setTextColor(txtUnselectedColor);
                    selectedIndex = (Integer) v.getTag();
                    setSelectState();
                    indicatorAnim(mTabs.get(selectedIndex), preTab);
                }
            });

先将之前的preTab恢复,再设置选中状态setSelectState(),最后indicator加上一个动画效果:

/**
     * 下面小角标的动画
     */
    private void indicatorAnim(Tab currentTab, Tab previousTab) {
        int scrollX = horizontalScrollView.getScrollX();
        int startValue = 0, endValue = 0;
        int startWidth = 0, endWidth = 0;

        switch (indicatorMode) {
            case EQUAL_TAB:
                startValue = previousTab.getTabLeft() - scrollX;
                endValue = currentTab.getTabLeft() - scrollX;
                startWidth = previousTab.getTabWidth();
                endWidth = currentTab.getTabWidth();
                break;
            case EQUAL_CONTENT:
            case EQUAL_VALUE:
                startValue = previousTab.getIndicatorLeft() - scrollX;
                endValue = currentTab.getIndicatorLeft() - scrollX;
                startWidth = previousTab.getIndicatorWidth();
                endWidth = currentTab.getIndicatorWidth();
                break;
        }
        ObjectAnimator anim1 = ObjectAnimator.ofInt(indicatorView, "rjp-left", startValue, endValue).setDuration(300);
        ObjectAnimator anim2 = ObjectAnimator.ofInt(indicatorView, "rjp-width", startWidth, endWidth).setDuration(300);
        anim1.start();
        anim2.start();
        anim1.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) indicatorView.getLayoutParams();
                layoutParams.setMargins((Integer) animation.getAnimatedValue(), 0, 0, 0);
                indicatorView.setLayoutParams(layoutParams);
            }
        });
        anim2.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) indicatorView.getLayoutParams();
                int width = (Integer) animation.getAnimatedValue();
                layoutParams.width = width;
                indicatorView.setLayoutParams(layoutParams);
            }
        });
        anim1.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {

            }

            @Override
            public void onAnimationEnd(Animator animation) {
                checkScroll();
            }

            @Override
            public void onAnimationCancel(Animator animation) {

            }

            @Override
            public void onAnimationRepeat(Animator animation) {

            }
        });
    }

动画有两部分,一部分是位置的动画,还有一个是长度的动画。拆分开来,使用属性动画,也是根据indicatorMode来区分:

  1. EQUAL_TAB的时候,位置动画起始值是previousTabLeft到currentTabLeft,长度动画起始值就是previousTabWidth到currentTabWidth;
  2. EQUAL_CONTENT和EQUAL_VALUE的时候,位置动画起始值是previousIndicatorLeft到currentIndicatorLeft,长度动画起始值就是previousIndicatorWidth到currentIndicatorWidth。

3.9 检查是否需要滚动

我们点击的如果是已经有一部分超出屏幕的Tab,需要滚动到屏幕中间,方便用户操作:

/**
     * 检查是否需要滑动
     */
    private void checkScroll() {
        View view = llTabContainer.getChildAt(selectedIndex);
        int left = view.getLeft();
        if (left > screenWidth / 2) {
            horizontalScrollView.smoothScrollTo(left - screenWidth / 2, 0);
        } else {
            horizontalScrollView.smoothScrollTo(0, 0);
        }
    }

这个方法在indicator的动画结束之后调用,否则会导致indicator错位。到这整个TabLayout就分析结束了,我给它取名字QuickTabLayout,因为真的很方便的调用和智能的选择TabMode,不信可以看demo。

四、案例

4.1 布局

<com.taovo.rjp.quicktablayout.QuickTabLayout
        android:id="@+id/quick_tab_layout1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        >

    </com.taovo.rjp.quicktablayout.QuickTabLayout>

4.2 activity代码

        ArrayList<Tab> tabs1 = new ArrayList<>();
        tabs1.add(new Tab("新闻新闻"));
        tabs1.add(new Tab("热点"));
        tabs1.add(new Tab("视频视频"));
        tabs1.add(new Tab("体育"));
        tabs1.add(new Tab("图片图片"));
        QuickTabLayout quickTabLayout1 = (QuickTabLayout) findViewById(R.id.quick_tab_layout1);
        quickTabLayout1.setTabMode(QuickTabLayout.TabMode.EQUANT);
        quickTabLayout1.setIndicatorMode(QuickTabLayout.IndicatorMode.EQUAL_TAB);
        quickTabLayout1.setTabs(tabs1);

设置的Tab均分屏幕,Indicator等于Tab的宽度,效果截图如下:

image.png

附上 GayHub 地址,感兴趣可以和我肛一波。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,393评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,790评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,391评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,703评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,613评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,003评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,507评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,158评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,300评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,256评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,274评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,984评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,569评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,662评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,899评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,268评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,840评论 2 339

推荐阅读更多精彩内容