效果图
与今日头条的部分区别:
1、选中的文字不会被放大(今日头条会放大一丢丢)
2、当item数超出屏幕时,滚动时机不同
使用方法
源码很少,建议直接复制即可(LyricIndicator.class 、LyricTextView.class、attrs.xml)
第一步,xml里引入
<com.example.lyricindicator.LyricIndicatorandroid: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>12345678910
可使用的属性有:
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);12
注意: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>1234567891011121314151617181920212223242526272829
<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); }1234567891011121314151617
可以看到设置好初始化属性后,还调用了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); }12345678
第三步
通过关联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(); } } }); }1234567891011121314151617181920212223242526272829303132
该方法中,我们获取到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); } } }12345678910111213141516
根据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); } }); } }123456789101112
点击后根据之前存好的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); } }12345678910111213141516
首先获取到滚动过程中涉及到的两个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; }123456789101112131415161718
这个时候有人要吐槽了,为什么不做的跟今日头条一样呢?哈哈哈哈哈哈哈哈哈大学读了四年数学已废。。。。试着写了好几次都没写出对应滑动距离的计算公式来。。。。
所以我只好查看了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(); }1234567891011
在每次滑动结束后调用一次这个方法,就能解决残影了。那在哪里调用呢?
第一个想到的是 onPageSelected
里,但是其实很多情景下onPageSelected
并不是在 onPageScrolled
调用结束后才调用的,有时候会先与onPageScrolled
调用。所以我还在onPageScrollStateChanged(int state)
方法里判断了当前状态,当状态是不在滑动时,即state == ViewPager.SCROLL_STATE_IDLE
时也调用了一次该方法。
残影问题就解决的,但是解决的不够优雅。
总结
本自定义view是继承了HorizontalScrollView ,经过反思,其实继承TabLayout会是更好的选择,坑也会少些。。毕竟练手作品,大家看看就好
另外,大家如果要动态设置一些属性的话,请自行添加setter/getter,别忘了setter里调用invalidate()重绘。