Android自定义view之ViewPager指示器——2
上一篇《Android自定义view之ViewPager指示器——1》中我们一起写了测量和布局的流程。本篇我们继续讲解事件分发,以及其他的功能性方法。
5. 事件分发
按照之前我们讲的事件分发流程,作为一个ViewGroup,按照顺序,首先来到的是onInterceptTouchEvent()
方法。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return true;
}
这个方法是决定当前的ViewGroup是否要拦截触摸事件。我们想要自己处理这些事件,所以返回true即可。
接下来事件就会到达onTouchEvent()
方法。
/*本次事件流是否已经被判断为滑动事件*/
private boolean moved = false;
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction())
{
/*按下的时候,对各个值进行赋值*/
case MotionEvent.ACTION_DOWN:
newX = event.getRawX();
newY = event.getRawY();
downX = newX;
downY = newY;
break;
/*发生滑动时,先更新值,然后用前一次触摸点的坐标和本次坐标进行计算,如果x方向上的移动距离大于touchSlop,那么
* 就判断为滑动。*/
case MotionEvent.ACTION_MOVE:
lastX = newX;
lastY = newY;
newX = event.getRawX();
newY = event.getRawY();
dx = newX - lastX;
dy = newY - lastY;
if(dx >= touchSlop)
{
moved = true;
}
/*判断TextView的布局。tags太多的话,是允许滑动的,但是滑动也有限制,第一个tag的最左边不可以大于指示器的paddingLeft。
* 最后一个tag的最右边不可以小于(height - paddingRight)。然后以这个条件来计算滑动后的第一个tag最左边的位置。
* 并进行重新布局,横线的位置及长短也要相应改变。*/
int left = tagMap.get(tags.get(0)).getLeft();
int right = tagMap.get(tags.get(tags.size() - 1)).getRight();
int length = right - left;
if (length < getMeasuredWidth() - getPaddingLeft() - getPaddingRight())
{
}else if(left + dx > getPaddingLeft())
{
left = getPaddingLeft();
}else if(right + dx < (getMeasuredWidth() - getPaddingRight()))
{
right = getMeasuredWidth() - getPaddingRight();
left = right - length;
}else
{
left += dx;
}
textOffset = left;
layoutChildren(left, indicatorLine.getLeft() + (left - tagMap.get(tags.get(0)).getLeft()), indicatorLine.getWidth());
break;
/*抬起时,判断从起点到落点的距离是否超过了touchSlop,如果不是,我们就判断它是点击事件,执行点击回调函数。否则就什么也不做。另外
* 将moved设为false,收尾。*/
case MotionEvent.ACTION_UP:
lastX = newX;
lastY = newY;
newX = event.getRawX();
newY = event.getRawY();
dx = newX - lastX;
dy = newY - lastY;
/*判断为点击事件*/
if(!moved && Math.abs(newX - downX) < touchSlop && Math.abs(newY - downY) < touchSlop)
{
for(int i = 0; i < tags.size(); i++)
{
TextView child = tagMap.get(tags.get(i));
if(child.getLeft() <= newX && child.getRight() >= newX)
{
notifyOnTagClickedListsners(i);
break;
}
}
}
moved = false;
break;
}
return super.onTouchEvent(event);
}
首先,我们有个变量moved,来判断当前事件流中是否出现过满足条件的滑动事件。根据这个情况我们可以在ACTION_UP时判断是否要判断为点击事件并调用点击函数。
方法中出现的几个变量需要说一下:newX和newY是新的事件的坐标;lastX和lastY是上一个事件的坐标;downX和downY是ACTION_DOWN事件的坐标。
另外有几个点需要说一下:
1. 在滑动事件的判断中,我们以前一次和本次事件的坐标差来判断,其实也可以再加入以本次事件和ACTION_DOWN事件坐标值差来判断,毕竟前者只计算相邻的两次事件的坐标差,如果用户缓慢移动的话,那可能滑了很久也不会被判断为移动,如果两种结合起来,判断会更为准确。
2. 在ACTION_MOVE的case中调用layoutChildren
的时机问题。严格来说应该在确实判断为滑动之后才调用来重新布局。不过这个控件因为体量较小,并且目前这种策略也没有什么问题。但是如果在写比如ListView这种大体量并且触摸操作很频繁的控件时还是要严格一点。
3. 没有对多指触摸进行优化,这会导致多指操作时出现跳动的情况。这在可滑动控件中是个比较严重的问题,不过好在指示器也并没有双指操作的设定。
我们主要的工作是在ACTION_MOVE的case里进行的,其实就是根据手指的移动距离来布局子view。onTouchEvent方法是需要返回一个boolean的,我们直接返回了父类的onTouchEvent()
的返回值。这里其实是View类的onTouchEvent()
方法,因为ViewGroup并没有重写这个方法。在View类中查看这个方法,我们发现只要这个View是可点击的,无论单击还是长按,这个方法就会返回true。而如果你仔细看了我们的构造函数,就会看到在里面我们写了this.setClickable(true)
。简而言之就是要在这里返回一个true表示我们消费了这个事件,所以你可以直接返回true。不过这里另外讲了一个点而已。
6. 与ViewPager交互
现在触摸事件也已经完成了,不过还没完,因为指示器肯定是需要和ViewPager做一些交互的,根据当前ViewPager的位置来进行变化。那我们就写一个监听ViewPager变化的方法。
/**使用在ViewPager.OnPageChangeListener.onPageScrolled(int position, float positionOffset, int positionOffsetPixels)
* 方法中,将三个参数原样传到该函数即可。
* @param position 当前可见的第一个页面的序号,如果positionOffset不为0,那么position + 1页面也是可见的。
* @param positionOffset 取值范围[0, 1),表示当前position页面的偏离范围。
* @param positionOffsetPixels 当前position页面的偏离值。
* */
public void listen(int position, float positionOffset, int positionOffsetPixels)
{
currentPosition = position;
/*需要让被选中的tag完整地显示出来,因此在tag布局在指示器的显示范围之外时需要移动,并且修改选中的和未选中的字的颜色。*/
if(positionOffset != 0.0f)
{
TextView current = tagMap.get(tags.get(position));
TextView old = tagMap.get(tags.get(position + 1));
int spaceBetweenTags = old.getLeft() - current.getLeft();
int lineLeft = current.getLeft() + (int)(spaceBetweenTags * positionOffset);
int currentLength = current.getWidth();
int oldLength = old.getWidth();
int lineLength = currentLength + (int)((oldLength - currentLength) * positionOffset);
int lineRight = lineLeft + lineLength;
if(lineLength >= (getMeasuredWidth() - getPaddingLeft() - getPaddingRight()))
{
textOffset += getPaddingLeft() - lineLeft;
lineLeft = getPaddingLeft();
}else if(lineLeft < getPaddingLeft())
{
textOffset += getPaddingLeft() - lineLeft;
lineLeft = getPaddingLeft();
// lineLength = lineRight - lineLeft;
}else if(lineRight > (getMeasuredWidth() - getPaddingRight()))
{
textOffset -= lineRight - (getMeasuredWidth() - getPaddingRight());
lineRight = (getMeasuredWidth() - getPaddingRight());
// lineLength = lineRight - lineLeft;
lineLeft = lineRight - lineLength;
}
layoutChildren(textOffset, lineLeft, lineLength);
current.setTextColor(evaluateColor(textColor, selectedTextColor, 1 - positionOffset));
old.setTextColor(evaluateColor(textColor, selectedTextColor, positionOffset));
}else
{
/*positionOffset == 0时说明此时已经完成了页面切换,有且仅有一个页面是被完整显示的,此时只要根据被选择的序号来布局即可。关于颜色
* 改变,因为在positionOffset == 0时我们已经丢失了页面切换的信息,所以无法得知上一个被选中的页面是哪个。另外,positionOffset == 0
* 的情况实际上很极端,因此对于这种情况的处理并不影响大局,连这种情况的位置布局都可以不必考虑*/
TextView current = tagMap.get(tags.get(position));
if(current.getLeft() < getPaddingLeft())
{
textOffset += getPaddingLeft() - current.getLeft();
}else if(current.getRight() > getMeasuredWidth() - getPaddingRight())
{
textOffset += getMeasuredWidth() - getPaddingRight() - current.getRight();
}
layoutChildren(textOffset, position);
}
}
上面的函数就是和ViewPager交互的主要函数,它可以监听ViewPager的页面转换信息并且同步改变布局。注意positionOffset == 0.0f
的情况是极端的,它只会在整个变换过程中占非常小的占比,因此对于它的处理是不影响大局的,即使将else分支里的代码整个删除也不会有问题。
在这里主要是变化tag和横线的位置,特殊情况是即将被选中的tag没有被完全显示,此时我们就需要根据情况改变tag的位置,使得最终被选中的tag能够完全显示。由于tag不一定都是等长的,因此横线的宽度也要根据变化的百分比进行改变,同样的还有文字的颜色。这里的positionOffset事实上就是变化的百分比,但是它并不是总是按照从0到1的顺序变化,为了方便,下面举个例子:
1. 如果当前显示的页面1号页面,然后我们向左滑动,想要显示2号页面。在没有滑动时,position == 1,positionOffset == 0。在滑动开始只到2号页面完全显示出来时,position == 1,而positionOffset则是从0到1开始变化(并不会到达1)。到2号页面完全显示出来,页面切换完成时,position == 2,positionOffset == 0。
2. 如果向右滑动,想要显示0号页面,在没有滑动时,情况和上面是一样的。在滑动过程中,position == 0,而positionOffset则是从1到0开始减小(并不会等于1)。到0号页面完全显示出来时,position == 0,positionOffset == 0。
可以看出position总是显示的第一个页面的序号,而positionOffset就是显示的第一个页面到后一个页面的变化的百分比。
7. 其他的功能性函数
添加删除tag
public void addTags(String[] tags)
{
for(String s : tags)
{
addTag(s);
}
}
public void addTag(String tag, int... index)
{
log.v("add tag");
/*新建TextView以容纳tag,并且TextView的宽和高都是WRAP_CONTENT的*/
TextView t = new TextView(getContext());
ViewGroup.MarginLayoutParams layoutParams = new ViewGroup.MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
t.setLayoutParams(layoutParams);
t.setText(tag);
t.setTextColor(textColor);
t.setTextSize(textSize);
t.setPadding(2,2,2,2);
if(index != null && index.length != 0)
{
if(index[0] <= currentPosition)
{
currentPosition++;
}
this.tags.add(index[0], tag);
}else
{
this.tags.add(tag);
}
this.tagMap.put(tag, t);
this.addView(t);
}
public void removeTag(String tag)
{
tags.remove(tag);
tagMap.remove(tag);
removeAllViews();
layoutChildren(textOffset, currentPosition);
}
获取当前选中tag的序号
public int getCurrentPosition()
{
return currentPosition;
}
监听器相关
public void addOnTagClickedListener(OnTagClickedListener l)
{
if(!onTagClickedListeners.contains(l))
{
onTagClickedListeners.add(l);
}
}
public void removeOnTagClickedListener(OnTagClickedListener l)
{
if(onTagClickedListeners.contains(l))
{
onTagClickedListeners.remove(l);
}
}
/*回调监听器的方法,以当前被点击的tag序号为参数*/
private void notifyOnTagClickedListsners(int position)
{
if(onTagClickedListeners.size() != 0)
{
for(OnTagClickedListener l : onTagClickedListeners)
{
l.onTagClicked(position);
}
}
}
/**tag点击监听器接口*/
public interface OnTagClickedListener
{
public void onTagClicked(int position);
}
颜色转换
颜色要各通道单独变化才可以,如果是将颜色整体作为一个int值来变化,那么中间就会出现其他颜色。
private int evaluateColor(int fromColor, int toColor, float percent)
{
int fromA = (fromColor >> 24) & 0xff;
int fromR = (fromColor >> 16) & 0xff;
int fromG = (fromColor >> 8) & 0xff;
int fromB = fromColor & 0xff;
int toA = (toColor >> 24) & 0xff;
int toR = (toColor >> 16) & 0xff;
int toG = (toColor >> 8) & 0xff;
int toB = toColor & 0xff;
int dA = (int)((toA - fromA) * percent);
int dR = (int)((toR - fromR) * percent);
int dG = (int)((toG - fromG) * percent);
int dB = (int)((toB - fromB) * percent);
int color = ((fromA + dA) << 24) | ((fromR + dR) << 16) | ((fromG + dG) << 8) | ((fromB + dB));
return color;
}
8. 使用方法
按照正常控件的使用方法即可。初始化后添加tag
indicator = (TextViewPagerIndicator)findViewById(R.id.view_pager_indicator);
indicator.addTag(tag);//可添加多个tag
然后设置各种回调函数即可。
viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
indicator.listen(position, positionOffset, positionOffsetPixels);
}
@Override
public void onPageSelected(int position) {
}
@Override
public void onPageScrollStateChanged(int state) {
}
});
indicator.addOnTagClickedListener(new TextViewPagerIndicator.OnTagClickedListener() {
@Override
public void onTagClicked(int position) {
viewPager.setCurrentItem(position, true);
}
});
9. 总结
这次我们真正从0开始自定义了一个指示器,用到了之前我们讲过的很多知识点。虽然这个自定义view还有一些瑕疵,但是整体已经可以使用了。这是我早期写的一个自定义view,后面会有比这个更加复杂的。有的部分分析写得比较简单,但是代码中的注释写得很明白的,并不复杂。
感兴趣的同学也可以上我的Github上查看,其中的CustomView工程有很多更复杂更高级的自定义view。也欢迎大家关注一波。源码也在这个工程中。