Andorid自定义View之波浪指示器

383 阅读3分钟

废话少说先看效果

156.gif

上班闲的无聊看到这个指示器有点意思就想自己写一下,先看一下布局结构

结构图.png

最外层是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

PathMeasure详解

而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) {

    }
}