自定义View之仿今日头条颜色渐变指示器导航栏

自定义View之仿今日头条颜色渐变指示器导航栏

前言

本文是自定义view的练习,默认读者掌握了自定义view的知识
本文是对本人上一篇写的控件《自定义view之歌词渐变文本控件》lyricTextView的封装应用。
源码地址:https://github.com/CCY0122/lyricindicator

效果图

这里写图片描述
与今日头条(v6.1.1)的部分区别:
1、选中的文字不会被放大(今日头条会放大一丢丢)
2、当item数超出屏幕时,滚动时机不同

使用方法

源码很少,建议直接复制即可(LyricIndicator.class 、LyricTextView.class、attrs.xml)
第一步,xml里引入

<com.example.lyricindicator.LyricIndicator
android:id="@+id/indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#11000000"
app:item_padding="7dp"
app:text_size="20sp"
app:default_color="#000000"
app:changed_color="#ff0000">
</com.example.lyricindicator.LyricIndicator>

可使用的属性有:
text_size 字体大小
default_color默认颜色
changed_color渐变颜色
字体的左右上下padding:
item_padding_l
item_padding_r
item_padding_t
item_padding_b
item_padding

注意:IDE可能还会列出text、progress、direction这些属性,这些属性属于lyricTextView,设置了也是无效的。
第二步:与viewpager进行关联:

lyricIndicator = (LyricIndicator) findViewById(R.id.indicator);
lyricIndicator.setupWithViewPager(mViewPager);

注意:ViewPager的adapter要实现 public CharSequence getPageTitle(int position)作为每一页对应的title

实现

第一步

首先,要学习lyricTextView

第二步

当然是在attrs里为我们的控件定义一些属性,贴上attrs:

<resources>

    <attr name="text_size" format="dimension" />
    <attr name="default_color" format="color|reference" />
    <attr name="changed_color" format="color|reference" />

    <declare-styleable name="LyricTextView">
        <attr name="text" format="string" />
        <attr name="text_size" />
        <attr name="default_color"/>
        <attr name="changed_color"/>
        <attr name="progress" format="float" />
        <attr name="direction">
            <enum name="left" value="0" />
            <enum name="right" value="1" />
        </attr>
    </declare-styleable>

    <declare-styleable name="LyricIndicator">
        <attr name="text_size"/>
        <attr name="default_color"/>
        <attr name="changed_color"/>
        <attr name="item_padding_l" format="dimension"/>
        <attr name="item_padding_r" format="dimension"/>
        <attr name="item_padding_t" format="dimension"/>
        <attr name="item_padding_b" format="dimension"/>
        <attr name="item_padding" format="dimension"/>
    </declare-styleable>
</resources>

<declare-styleable name="LyricTextView"></> 里的是lyricTextView 的属性。<declare-styleable name="LyricIndicator"></> 里的是本控件的属性,这里注意,text_size、default_color、changed_color是这两个控件都有的,相同属性不允许重复定义,所以我们要提出来在开头就定义,否则报错。这些属性作用应该看下名字都能理解。
然后呢,创建LyricIndicator继承自HorizontalScrollView。实现前三个构造,构造方法里初始化属性:

 public LyricIndicator(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        this.context = context;

        TypedArray t = context.obtainStyledAttributes(attrs, R.styleable.LyricIndicator);
        textSize = t.getDimension(R.styleable.LyricIndicator_text_size, sp2px(14));
        defaultColor = t.getColor(R.styleable.LyricIndicator_default_color, DEFAULT_COLOR);
        changeColor = t.getColor(R.styleable.LyricIndicator_changed_color, CHANGED_COLOR);
        padding = (int) t.getDimension(R.styleable.LyricIndicator_item_padding, 0);
        paddingL = (int) t.getDimension(R.styleable.LyricIndicator_item_padding_l, padding);
        paddingR = (int) t.getDimension(R.styleable.LyricIndicator_item_padding_r, padding);
        paddingT = (int) t.getDimension(R.styleable.LyricIndicator_item_padding_t, padding);
        paddingB = (int) t.getDimension(R.styleable.LyricIndicator_item_padding_b, padding);
        t.recycle();

        addBaseView(context);
    }

可以看到设置好初始化属性后,还调用了addBaseView(context)。我们的控件是继承自HorizontalScrollView的,它的内部应只有一个子布局,那我们就放一个方向为水平的LinearLayout,然后之后添加的的item(即LyricTextView)都放在这个LinearLayout里。

    private void addBaseView(Context context) {
        baseLinearLayout = new LinearLayout(context);
        baseLinearLayout.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
        baseLinearLayout.setOrientation(LinearLayout.HORIZONTAL);
        baseLinearLayout.setGravity(Gravity.CENTER_VERTICAL);

        addView(baseLinearLayout);
    }

第三步

通过关联viewPager来完成控件初始化。
关联viewPager后,我们的控件就与它进行了绑定。首先,我们要根据viewPager的页数来生成对应数量的item,并监听viewPager的滚动事件,监听item们的点击事件。关联代码如下:

/**
     * 关联viewpager,
     * @param vp
     */
    public void setupWithViewPager(final ViewPager vp) {
        this.vp = vp;
        if ( vp == null || vp.getAdapter() == null) {
            return;
        }
        addLyricTextViews();
        addClickEvent();
        vp.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
            @Override
            public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
                itemScroll(position, positionOffset);
            }

            @Override
            public void onPageSelected(int position) {
                Log.d("ccy", "onPageSelected" + position);
                resetAllItem();
            }

            @Override
            public void onPageScrollStateChanged(int state) {
                if(state == ViewPager.SCROLL_STATE_IDLE){  //解决残影,不够完美
                    resetAllItem();
                }
            }
        });

    }

该方法中,我们获取到viewPager之后,首先调用了addLyricTextViews() 来生成对应每一页的item,然后调用addClickEvent() 为item们添加点击事件,然后监听了viewpager的滚动事件,在滚动时,即在 onPageScrolled回调里,我们通过itemScroll(position, positionOffset) 来进行两个item之间的颜色渐变,即当前的item的进度progress要从1 –> 0,并且方向direction设置为右,而即将选中的item的进度progress要从0 –> 1,并且方向direction设置为左。
onPageScrolled回调中的参数说明:假设当前选中的item是2,如果当前滑动方向是从左往右时,position为2,positionOffset为[0,1)中的一个值,也即滑动的比例,并且是从0慢慢增加到1;如果当前滑动方向是从右往左,那么position的值就为1了(虽然当前选中position为2),positionOffset是从1慢慢减少到0。

下面看下addLyricTextViews() 方法:

/**
     * 添加所有item
     */
    private void addLyricTextViews() {
        currentPos = vp.getCurrentItem();
        for (int i = 0; i < vp.getAdapter().getCount(); i++) {
            LyricTextView ltv = new LyricTextView(context);
            ltv.setAll(0f, vp.getAdapter().getPageTitle(i)+"", textSize, defaultColor, changeColor, LyricTextView.LEFT);
            ltv.setPadding(paddingL, paddingT, paddingR, paddingB);
            ltv.setTag(i);
            baseLinearLayout.addView(ltv);
            if (i == currentPos) {
                ltv.setProgress(1);
            }
        }
    }

根据vp.getAdapter().getCount()获取到数量,然后初始化对应数量的LyricTextView,并添加到父布局baseLinearLayout里,将当前选中的item的progress设为1。
这里LyricTextView里的text是从vp.getAdapter().getPageTitle(i) 里获取而来的,一次我们写viewPager的adapter的时候记得要重写这个方法。

接下来看addClickEvent()

 private void addClickEvent() {
        for (int i = 0; i < baseLinearLayout.getChildCount(); i++) {
            LyricTextView ltv = (LyricTextView) baseLinearLayout.getChildAt(i);
            ltv.setOnClickListener(new OnClickListener() {
                @Override
                public void onClick(View v) {
                    int pos = (int) v.getTag();
                    vp.setCurrentItem(pos);
                }
            });
        }
    }

点击后根据之前存好的tag选中对应item,不用说什么了。

接下来看 onPageScrolled回调里的itemScroll(position, positionOffset)

 private void itemScroll(int position, float positionOffset) {
        if (positionOffset > 0 && position + 1 <= vp.getAdapter().getCount()) {
            LyricTextView left = (LyricTextView) baseLinearLayout.getChildAt(position);
            LyricTextView right = (LyricTextView) baseLinearLayout.getChildAt(position + 1);
            left.setDirection(LyricTextView.RIGHT);
            left.setProgress(1 - positionOffset);
            right.setDirection(LyricTextView.LEFT);
            right.setProgress(positionOffset);
            invalidate();

            layoutScroll(position, positionOffset);
        }
    }


首先获取到滚动过程中涉及到的两个item,坐边的叫left,右边的叫right。
之前已经解释过了int position、float positionOffset这两个参数。我再啰嗦一下:当从左往右滑,那么left即当前选中的item,right是即将选中的item;当从右往左滑,left是即将要选中的item,right是当前选中的item。
如果你理解了,那么之后他俩setDirection和setProgress里填的值也肯定就理解了。然后记得invalidate。
然后呢,还调用了一个方法layoutScroll(position, positionOffset); 这个方法就是当item数总长度超过控件宽度时,后面的item总要在某个时刻滑出来的吧。今日头条app(v6.1.1)里滑动时机是当前item为最后一个或第一个完整可见的item时,才开始滑动(听不懂?打开今日头条看看新闻去吧)而我们的控件滑动时机是当前选中item在控件中心时开始滑动(听不懂?看效果图)。

layoutScroll 代码:

 private void layoutScroll(int pos, float positionOffset) {
//        Log.d("ccy","scroll x = " + calculateScrollXForTab(pos, positionOffset));
        scrollTo(calculateScrollXForTab(pos, positionOffset), 0);
    }

    private int calculateScrollXForTab(int pos, float positionOffset) {
        LyricTextView selectedChild = (LyricTextView) baseLinearLayout.getChildAt(pos);
        LyricTextView nextChild = (LyricTextView) baseLinearLayout.getChildAt(pos + 1);
        final int selectedWidth = selectedChild != null ? selectedChild.getWidth() : 0;
        final int nextWidth = nextChild != null ? nextChild.getWidth() : 0;
        // base scroll amount: places center of tab in center of parent
        int scrollBase = selectedChild.getLeft() + (selectedWidth / 2) - (getWidth() / 2);
        // offset amount: fraction of the distance between centers of tabs
        int scrollOffset = (int) ((selectedWidth + nextWidth) * 0.5f * positionOffset);
        return (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_LTR)
                ? scrollBase + scrollOffset
                : scrollBase - scrollOffset;
    }

这个时候有人要吐槽了,为什么不做的跟今日头条一样呢?哈哈哈哈哈哈哈哈哈大学读了四年数学已废。。。。试着写了好几次都没写出对应滑动距离的计算公式来。。。。
所以我只好查看了TabLayout的源码(还是读源码叼),把calculateScrollXForTab拿来用了~~~~大家好好读一读calculateScrollXForTab,好好理解,就是计算scroll的距离,这个文字解释好麻烦。另外,读完后我还学到了原来ViewCompat.getLayoutDirection(this)可以判断当前滑动方向的(你早知道了?好吧……)。

好了,主体算是完成了,测试一下,滑动viewPager,恩,LyricIndicator也跟着滑动了,这个没问题。那直接点击某个item呢,咦,虽然能选中,但是之前的item居然留下了一点残影
这里写图片描述
上图是原本选中的是“111”,然后我点击了“asdasdasd”之后的效果图,可以看到111居然还有一点点是红色的。
这是为什么呢,根据我自己的排查,我认为原因是这样的:
OnPageChangeListener里onPageScrolled这个方法呢是在滑动过程中不断回调的,positionOffset的值是[0,1)之间,那么一次正常滑动的话可能最后一次调用onPageScrolled时positionOffset的值已经是0.99等非常接近1的值,但是如果滑动速度比较快(我们通过点击选中一个item,viewPager会快速滑动过去),最后一次的positionOffset值可能只有0.95等不那么接近1的值,这就导致了上一个item的留下了0.5的progress,也就是上图“111”留下的一点点红色。
咋解决的,先写这么个方法:

private void resetAllItem() {
        for (int i = 0; i < baseLinearLayout.getChildCount(); i++) {
            LyricTextView ltv = (LyricTextView) baseLinearLayout.getChildAt(i);
            if (i == vp.getCurrentItem()) {
                ltv.setProgress(1f);
            } else {
                ltv.setProgress(0f);
            }
        }
        invalidate();
    }

在每次滑动结束后调用一次这个方法,就能解决残影了。那在哪里调用呢?
第一个想到的是 onPageSelected 里,但是其实很多情景下onPageSelected 并不是在 onPageScrolled 调用结束后才调用的,有时候会先与onPageScrolled调用。所以我还在onPageScrollStateChanged(int state) 方法里判断了当前状态,当状态是不在滑动时,即state == ViewPager.SCROLL_STATE_IDLE 时也调用了一次该方法。
残影问题就解决的,但是解决的不够优雅。

总结

本自定义view是继承了HorizontalScrollView ,经过反思,其实继承TabLayout会是更好的选择,坑也会少些。。毕竟练手作品,大家看看就好
另外,大家如果要动态设置一些属性的话,请自行添加setter/getter,别忘了setter里调用invalidate()重绘。

2017-06-16更新

小小更新下。
1、今日头条指示器的滑动时机大概在指示器的宽度的0.8~0.9左右比例处,我的指示器的滑动时机是正中心(宽度的0.5处)。上面代码中private int calculateScrollXForTab(int pos, float positionOffset) 这个方法里面,有个值是这样的
int scrollBase = selectedChild.getLeft() + (selectedWidth / 2) - (getWidth() / 2);
我们可以稍作修改:
int scrollBase = selectedChild.getLeft() + (selectedWidth / 2) - (getWidth()*PIVOT_X);
其中PIVOT_X代表一个比例值(0~1),想跟今日头条一样的话就赋值为0.8左右就可以啦~~大家可以赋多种值试试效果。
2、推荐大家去学习MagicIndicator 这个指示器的库,内置了很多效果,也很方便扩展自定义,比我这练手作品不知道强到哪去了,而且学完后让我体会到了面向接口编程的重要性!这个库的作者的博客里有对这个库的解析文章,鸿洋大大也推荐过,推荐大家学习。

猜你喜欢

转载自blog.csdn.net/ccy0122/article/details/72902977