您的位置:首页 > 移动开发 > Android开发

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

2017-06-07 19:05 357 查看

自定义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 这个指示器的库,内置了很多效果,也很方便扩展自定义,比我这练手作品不知道强到哪去了,而且学完后让我体会到了面向接口编程的重要性!这个库的作者的博客里有对这个库的解析文章,鸿洋大大也推荐过,推荐大家学习。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息