废话少说先看效果
上班闲的无聊看到这个指示器有点意思就想自己写一下,先看一下布局结构
最外层是HorizontalScrollView,包裹着LinearLayout,里面再是TextView,指示器就直接绘制在HorizontalScrollView里,位于LinearLayout下面。
先梳理一下思路,HorizontalScrollView是为了当标题划出屏幕外时实现滚动,然后要先绘制好指示器的路径,因为都是曲线所以要用二阶贝塞尔曲线,如下:
path.quadTo(float x1, float y1, float x2, float y2)
其中x1,y1是控制点的坐标,x2,y2是结束点的坐标,不了解贝塞尔曲线的可点击下方链接去学习
每个标题之间的间距一般来说都是相等的,标题的长度可能不等,因为包裹TextView的Linearlayout在 HorizontalScrollView中的Width是占满的所以可以根据TextView在LinearLayout中的坐标来绘制在HorizontalScrollView中的Path路径
第一个标题下的曲线绘制(伪代码):
起点坐标:
X = textView1.getLeft
Y = texView1.getBottom + 距离标题的间隔
path.moveTo(X,Y) //移动到起点
终点坐标:
X1 = textView1.getRight
控制点坐标(起点到终点距离的二分之一):
X2 = X+(X1-X)/2
Y2 = Y + 一定的距离
path.quadTo(X2,Y2,X1,Y) //绘制标题下的曲线
后面的曲线绘制以此类推,绘制标题曲线时控制点的纵坐标就是Y+一定的距离,绘制间隔曲线的控制点就是Y-一定的距离,且 一定距离要小于<距离标题的间隔写的有些啰嗦但还是重在理解
重点说一下这个方法,动效都靠它和刷新方法实现
pathMeasure.getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo)
PathMeasure不懂的请点击下面链接学习,写的太累不想多写了,简单来说就是关联一个Path,然后就可以测量这个Path
而getSegment方法就是截取一段Path并添加到新的Path中,看到这是不是有一种灵感迸发或者恍然大悟的感觉,先说一下参数:
startD:开始截取位置距离 Path 起点的长度
stopD:结束截取位置距离 Path 起点的长度
Path:截取的 Path 将会添加到 dst 中
startWithMoveTo:是否原样截取,一般都true
因为都是曲线所以长度不能直接获取,必须通过PathMeasure来测量,间距测量一次就好,然后将标题下测量好的曲线长度存入ArrayList和标题顺序一一对应就好。
将自定义View实现ViewPager.OnPageChangeListener接口,通过方法:
onPageScrolled(int position, float positionOffset, int positionOffsetPixels)
获取当前ViewPager的Position和滑动比例positionOffset,onPageScrolled方法会在滑动过程中不断调用所以要在其中调用重绘方法invalidate();
以上的绘制只要了解API还是比较好绘制的,麻烦的是pathMeasure.getSegment的长度的动态计算,我先写到这儿吧有点累了,后面的我有空再更新~ ~,先附上源码 ~,我里面的注释也写的很详细,而且还很贴心的把外部引用的XML都在代码里用代码创建了,CV就能用,多贴心,新人望点赞,如有错误请指正,多谢
public class TabView extends HorizontalScrollView implements ViewPager.OnPageChangeListener{
private Paint paint;
private Path path1;
private Path path2;
private PathMeasure pathMeasure;
private PathMeasure pathMeasure2;
private LinearLayout titleLayout;
private ArrayList<String> titles;
private Context context;
private ViewPager viewPager;
private int currentPosition = 0;
private float positionOffset = 0;
private ArrayList<Float> arrayList = new ArrayList<>();
public TabView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
this.context = context;
init();
}
private void init(){
paint = new Paint();
paint.setStyle(Paint.Style.STROKE);
paint.setAntiAlias(true);
paint.setColor(Color.BLACK);
paint.setStrokeWidth(5);
path1 = new Path(); //总路径
path2 = new Path(); //截取的路径
pathMeasure = new PathMeasure(path1,false);
pathMeasure2 = new PathMeasure(path2,false);
titleLayout = new LinearLayout(context);
addView(titleLayout);
LayoutParams layoutParams = new LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.WRAP_CONTENT);
titleLayout.setLayoutParams(layoutParams);
}
public void initData(ViewPager viewPager, String[] title){
titles = new ArrayList<>();
this.viewPager = viewPager;
viewPager.addOnPageChangeListener(this);
Collections.addAll(titles, title);
addTabTitle();
}
private void addTabTitle(){
for (String title : titles) {
TextView textView = new TextView(context);
textView.setText(title);
titleLayout.addView(textView);
LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) textView.getLayoutParams();
layoutParams.leftMargin = 50; //添加标题左右边距各50像素
layoutParams.rightMargin = 50;
textView.setLayoutParams(layoutParams);
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int childCount = titleLayout.getChildCount();
int lastRight = 0;
//重绘时避免状态混乱必须清空
arrayList.clear();
path2.reset();
path1.reset();
float arcLength = 0; //标题之间的弧长 固定的(因为每个标题的左右边距相同)
for (int i = 0; i < childCount; i++) {
TextView textView = (TextView) titleLayout.getChildAt(i);
int bottom = textView.getBottom()+30; //指示器绘制在标题下方的30像素位置
int right = textView.getRight(); //确定每个指示器位于标题的终点位置(X)
int left = textView.getLeft(); //确定每个指示器位于标题的起点位置(X)
if (i == 0) {
path1.moveTo(50,bottom); //因为第一个标题左边距有50,所以第一个指示器起点要移动到50 bottom位置
path1.quadTo((right-50)/2+50, bottom+20,right,bottom); //二阶贝塞尔曲线绘制 控制点X点位于指示器起点与终点的二分之一的位置 Y点是bottom+20
path2.moveTo(50,bottom); //path2是为了测量这段弧长的距离
path2.quadTo((right-50)/2+50, bottom+20,right,bottom);
}else {
path1.quadTo(lastRight+50,bottom-20,left,bottom); //绘制间距弧长
path2.moveTo(lastRight,bottom);
path2.quadTo(lastRight+50,bottom-20,left,bottom);
pathMeasure2.setPath(path2,false); //这个方法必须在每次path改变是重新调用 否则测量结果为零(很蛋疼我也不知道为啥)
arcLength = pathMeasure2.getLength(); //因为间距固定 所以间距的弧长也固定 赋值给一个变量等待调用
path2.reset(); //重置状态
path1.quadTo((right-left)/2+lastRight+100,bottom+20,right,bottom);//绘制标题弧长
path2.moveTo(left,bottom);
path2.quadTo((right-left)/2+lastRight+100,bottom+20,right,bottom);//测量标题弧长
}
pathMeasure2.setPath(path2,false);
arrayList.add(pathMeasure2.getLength()); //因为每个标题的长度不一样 根据标题顺序将每个测量出来的弧长添加进集合
path2.reset();
lastRight = right; //这个看代码自行理解吧
}
paint.setColor(Color.BLACK);
// canvas.drawPath(path1,paint); //绘制总路径
// pathMeasure.getSegment(start, stop, path, true);
// 上面这个方法是重点,方法是用于截取一段path添加到新的path,第一个参数是距离被截取path起点的长度,重点注意是长度不是坐标!!!
// 第二个参数是截取的终点距离被截取path起点的长度,也是长度!!!
// 第三个参数是将截取的path添加到新的path对象中,是添加不是覆盖
// 第四个参数是,是否按截取的起点绘制,一般选择true,要是false的话会从(0,0)点连接的
//下面的代码理解可能有些绕
float startLength = 0; //当前起点距被截取起点的长度
float endLength = 0; //当前终点距被截取起点的长度
float nextEndLength = 0; //下一个标题起点距被截取起点的长度
float nextStartLength = 0; //下一个标题终点距被截取起点的长度
for (int i = 0; i <= currentPosition; i++) {
endLength = endLength + arrayList.get(i); //终点的长度是每个标题指示器的长度相加
if (i != currentPosition) {
startLength = startLength + arrayList.get(i); //起点的长度是每个标题指示器的长度相加但不包括当前标题
}
if (currentPosition != titles.size()-1) { //避免下标越界
nextEndLength = endLength + arrayList.get(i+1); //上面都+1就是下一个的嘛
nextStartLength = startLength + arrayList.get(i);
}
}
endLength = endLength + currentPosition*arcLength; //都加上每个间隔的弧长,弧长都一样乘一下就好了
startLength = startLength + currentPosition*arcLength;
if (currentPosition != titles.size() - 1) { //避免下标越界
nextEndLength = nextEndLength + (currentPosition+1)*arcLength;
nextStartLength = nextStartLength + (currentPosition+1)*arcLength;
}
//下面是实现动效的核心,我也是想了一天试错好几次才成功的
//positionOffset 是滑动的百分比 往前0-1 往回1-0
//currentPosition当前的页下标
pathMeasure.setPath(path1,false);
//首页的时候起点滑动的距离就是下一个标题起点的长度nextStartLength * positionOffset
//终点的距离就是现在终点的长度endLength加上到下个标题终点的距离nextEndLength - endLength 再乘以滑动百分比positionOffset
if (currentPosition == 0) {
pathMeasure.getSegment(nextStartLength * positionOffset, endLength + (nextEndLength - endLength) * positionOffset, path2, true);
}else {
//非首页的起点滑动的距离是当前起点的长度startLength,加上当前起点到下一个起点的距离nextStartLength-startLength,动效就乘以滑动百分比positionOffset
//终点同上
pathMeasure.getSegment(startLength+(nextStartLength-startLength) * positionOffset, endLength + (nextEndLength - endLength) * positionOffset, path2, true);
}
//最后绘制 搞定!
paint.setColor(Color.RED);
canvas.drawPath(path2,paint);
}
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
currentPosition = position;
this.positionOffset = positionOffset;
invalidate();
}
@Override
public void onPageSelected(int position) {
View childAt = titleLayout.getChildAt(position);
int right = childAt.getRight();
int left = childAt.getLeft();
if (right > getWidth()) {
smoothScrollTo(right,0);
}
if (getScrollX() > left) {
smoothScrollTo(left,0);
}
}
@Override
public void onPageScrollStateChanged(int state) {
}
}