ViewPager2水滴与吸附式切换效果

ViewPager1/2水滴与吸附式切换效果

本小白为了完成ViewPager2底部圆点导航切换的效果,结合网上资料,目前完成了以下两种效果,很多部分还需要完善。

现有效果

吸附式效果
水滴效果

TestDemo的核心代码
github: https://github.com/ArtsSTAR/ViewPager2Navigation

参考代码:

吸附式效果:
github: https://github.com/Hoxx/BesselViewPagerPoint
水滴效果:
github: https://github.com/DevinShine/MagicCircl

以上ViewPager的圆点导航效果都是基于贝塞尔曲线动态闭合绘制而成。那么什么是贝塞尔(Bezier)曲线呢?

贝塞尔曲线

百度百科定义:

贝塞尔曲线(Bézier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。一般的矢量图形软件通过它来精确画出曲线,贝兹曲线由线段节点组成,节点是可拖动的支点,线段像可伸缩的皮筋,我们在绘图工具上看到的钢笔工具就是来做这种矢量曲线的。

简单来说,就是给定起始点和终点,以及中间的若干个控制点(控制线),即可绘制出一条光滑曲线

在Android里可通过Path类,完成贝塞尔曲线绘制

贝塞尔方程,可分为一次方、二次方、三次方以及一般性方程式。

贝塞尔方程分类

  • 一次方方程(线性公式)
    一次方公式

一次方方程式即只有始末点,无控制点,则为一条直线。Android里的Path实现对应的是直线的绘制方法:

moveTo 移动(设置)起点位置

lineTo 从一个点,链接到该点,如未设置上一点,则从原点开始

其他Path方法:

closeTo当前点和起点位置相连,如果无法形成闭合图形,则什么也不做。

reset rewind清除Path中的内容(reset相当于重置到new Path阶段,rewind会保留Path的数据结构)

  • 二次方方程
    二次方方程

二次方方程式有始末点和一个控制点。

效果图:
在这里插入图片描述

绘制二次贝塞尔曲线方法:

quadTo(float x1,float y1,float x2, float y2)

其中x1、y1为控制点坐标

X2、y2为终点坐标

  • 三次方方程
    三次方方程

圆点
三次方方程式即有始末点和两个控制点。

在本文的水滴效果切换,就是通过四条三次方贝塞尔曲线圆构成
cubicTo(float x1,float y2,float x2,float y2,float x3,float y3)

其中x1、y1、x2、y2为中间两个控制点

x3、y3为终点坐标

详细方程式可见百度百科

吸附式效果数学原理解剖图

在这里插入图片描述

根据之前的贝塞尔曲线和实际效果可以知道,在Android里绘制选中的部分就是两个圆,加上中间所围的闭合图形,它由两条p1到p2、p3到p4的两条直线,以及p2到p3经p5的控制点和p4到p1经p5的两条二次贝塞尔曲线构成。

其中p5为两圆点连线与中垂线的相交坐标。

P1-P4点坐标则根据两圆心坐标和三角函数可求出

		private void calculationBezierControlCirclePoint (float pax, float pay, float pbx, float pby) {
    		double a = Math.atan((pbx - pax) / (pby - pay));
   	 		double sin = Math.sin(a);
    		double cos = Math.cos(a);
     		p1.Y = (float) (pay + (sin * 100));
        p1.X = (float) (pax - (cos * 100));

        p2.X = (float) (pax + cos * 100);
        p2.Y = (float) (pay - sin * 100);
    
        p3.X = (float) (pbx - cos * 100);
        p3.Y = (float) (pby + sin * 100);
    
        p4.X = (float) (pbx + cos * 100);
        p4.Y = (float) (pby - sin * 100);
    
        p5.X = (pax + pbx) / 2;
        p5.Y = (pay + pby) / 2;
}

计算闭合图形:

//计算贝塞尔曲线Path
private void calculationBezierPath(Canvas canvas) {
    mPath.reset();
    mPath.moveTo(p1.X, p1.Y);
    mPath.quadTo(p5.X, p5.Y, p3.X, p3.Y);
    mPath.lineTo(p4.X, p4.Y);
    mPath.quadTo(p5.X, p5.Y, p2.X, p2.Y);
    mPath.lineTo(p1.X, p1.Y);
    canvas.drawPath(path, paint);
}

吸附式效果状态分解

大致可分为以下3种状态:

状态一(圆点):
在这里插入图片描述

状态二(两圆+矩形):

在这里插入图片描述

Android计算矩形方框实现:

//计算矩形Path(用于动态圆和定点圆未完全脱离时绘制)
private void calculationRectanglePath() {
    mPath.moveTo(p1.X, p1.Y);
    mPath.lineTo(p3.X, p3.Y);
    mPath.lineTo(p4.X, p4.Y);
    mPath.lineTo(p2.X, p2.Y);
    mPath.close();
}

状态三(两圆+贝塞尔曲线):

在这里插入图片描述

整个两页面过程的变形大致为:1 -> 2 -> 3 -> 1 -> 3 -> 2 -> 1

吸附式效果Android设计与实现

整个切换过程可以理解为绘画n个定圆(页面数量),外加一个动圆,以及动圆和某个定圆绘制起来的贝塞尔曲线。

接下来上onDraw代码

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    if (mCirclePointList == null || mCirclePointList.size() <= 0)
        return;
    // 绘制定圆
    for (int i = 0; i < mCirclePointList.size(); i++) {
        if (currentIndex == i) {
            mCirclePointList.get(i).onDraw(canvas, mSelectColor);
        } else {
            mCirclePointList.get(i).onDraw(canvas, mNormalColor);
        }
    }
    // 绘制动圆
    mAnimCirclePoint.onDraw(canvas, mSelectColor);
    // 计算Path路径
    calculationSelectPath();  
    // 绘制Path路径
    canvas.drawPath(mPath, mPaint);
}

mCirclePointList封装的是一个个大圆,它包含画笔、半径、横坐标、纵坐标。

以下是Circle的核心代码:

public void onDraw(Canvas canvas,int color){
    if(p == null) return;
    paint.setColor(color);
    canvas.drawCircle(p.X,p.Y,p.radius,paint); //画一个圆
}

private void initPaint(){ //会在构造函数中执行
    paint = new Paint();
    paint.setAntiAlias(true);
    paint.setStyle(Paint.Style.FILL_AND_STROKE);
    paint.setStrokeWidth(2);
}
  • 动圆状态变化

    前面提到了吸附式效果的状态变化,接下来就让我们看一下每种状态的代码实现和判定条件吧。

private void calculationSelectPath() {
    fixedPX = mCirclePointList.get(currentIndex).getP().X; //选中定圆的横坐标
    fixedPY = mCirclePointList.get(currentIndex).getP().Y;  //选中定圆纵坐标
    animPX = mAnimCirclePoint.getP().X;  //动态圆横坐标
    animPY = mAnimCirclePoint.getP().Y;   //动态圆纵坐标

    // 通过两个圆的圆心点坐标,计算出Bezier曲线(二阶)的五个点(p1 - p5)坐标
    calculationBezierControlCirclePoint(fixedPX, fixedPY, animPX, animPY);

    // 计算固定圆和动态圆之间距离
    mBetweenFixedAndDynamicCircleDistance = Math.abs(Math.sqrt(Math.pow(fixedPX - animPX, 2) + Math.pow(fixedPY - animPY, 2)));

    // 路径重置
    mPath.reset();

  	// 状态二,即两圆重叠状态,高亮两圆(onDraw已绘制出)+ 矩形方框
    if (mBetweenFixedAndDynamicCircleDistance <= mCirCleRadius * 2) {
        // 绘制矩形方框路径
        calculationRectanglePath();
    } else if (mBetweenFixedAndDynamicCircleDistance > mCirCleRadius * 2
            && mBetweenFixedAndDynamicCircleDistance < mCircleItemCenterLength - mKeepFixedCircleStateOffset) { // 状态3 即含有贝塞尔曲线状态
        //绘制贝塞尔曲线
        calculationBezierPath();
    } else if (mBetweenFixedAndDynamicCircleDistance > mCircleItemCenterLength - mKeepFixedCircleStateOffset
            && mBetweenFixedAndDynamicCircleDistance < mCircleItemCenterLength + mKeepFixedCircleStateOffset) { // 状态1 保持中间定圆状态
        //取消绘制贝塞尔曲线
    } else if (mBetweenFixedAndDynamicCircleDistance >= mCircleItemCenterLength + mKeepFixedCircleStateOffset) { // 改变Index,轮回
        //切换下一个圆,以此圆为基础计算Path路径,然后绘制
        if (currentIndex <= mCirclePointList.size() - 1) {
            if (fixedPX > animPX) {//动圆位于当前圆的左侧
                currentIndex = currentIndex - 1;
            } else {//动圆位于当前圆的右侧
                currentIndex = currentIndex + 1;
            }
        }
    }
}
// tips:为了更好的体现状态1,这里有一个偏移量(mKeepFixedCircleStateOffset)的参数

到此基本上吸附式效果的核心状态实现就结束了,但怎么让页面滑动比例转化为动圆的移动变化呢?

我使用了ViewPager2onPageScrolled( position: Int, positionOffset: Float, positionOffsetPixels: Int)方法的positionOffset(页面滑动偏移量)来计算。

    public void setTranslateX(Float x,int mPosition) {
        if (mAnimCirclePoint != null && mAnimCirclePoint.getP() != null) {
            // 此处计算方式为itemWidth*数量+中心点距即让圆心处于Item的中点+偏移百分比*间距即ItemWidth
            mAnimCirclePoint.getP().X = mCircleItemWidth * mPosition + (mCircleItemWidth * x) + mCircleItemCenterLength;
        }
        invalidate();  //刷新视图
    }

这个方法中mCircleItemWidth指的是(该圆点导航控件大小/圆点数量),x为外面传进来的positionOffset

其他核心代码可参考:

参考资料:https://blog.csdn.net/u012137156/article/details/70229082

水滴状效果数学原理解剖图

根据上面这个图以及前面贝塞尔曲线的知识,你可以发现一个贝塞尔圆他由12个点组成,而你只需要知道M和中心点坐标值和半径R就可以知道12个点的全部坐标了。

根据stackoverflow和网上的相关资料,这个m的值大约为0.551915024494f,论证文章可以参考文章1文章2

以下就是坐标点的计算:

private void calculateFixedCirCleCoordinatesData() {
    // 控制点与数据点坐标计算,解释可见图
    mPointsArray[11].X = mCenterPoint.X - (M * mCirCleRadius);
    mPointsArray[11].Y = mCenterPoint.Y + mCirCleRadius;
    mPointsArray[0].X = mCenterPoint.X;
    mPointsArray[0].Y = mCenterPoint.Y + mCirCleRadius;
    mPointsArray[1].X = mCenterPoint.X + (M * mCirCleRadius);
    mPointsArray[1].Y = mCenterPoint.Y + mCirCleRadius;

    mPointsArray[2].X = mCenterPoint.X + mCirCleRadius;
    mPointsArray[2].Y = mCenterPoint.Y + (M * mCirCleRadius);
    mPointsArray[3].X = mCenterPoint.X + mCirCleRadius;
    mPointsArray[3].Y = mCenterPoint.Y;
    mPointsArray[4].X = mCenterPoint.X + mCirCleRadius;
    mPointsArray[4].Y = mCenterPoint.Y - (M * mCirCleRadius);

    mPointsArray[5].X = mCenterPoint.X + (M * mCirCleRadius);
    mPointsArray[5].Y = mCenterPoint.Y - mCirCleRadius;
    mPointsArray[6].X = mCenterPoint.X;
    mPointsArray[6].Y = mCenterPoint.Y - mCirCleRadius;
    mPointsArray[7].X = mCenterPoint.X - (M * mCirCleRadius);
    mPointsArray[7].Y = mCenterPoint.Y - mCirCleRadius;

    mPointsArray[8].X = mCenterPoint.X - mCirCleRadius;
    mPointsArray[8].Y = mCenterPoint.Y - (M * mCirCleRadius);
    mPointsArray[9].X = mCenterPoint.X - mCirCleRadius;
    mPointsArray[9].Y = mCenterPoint.Y;
    mPointsArray[10].X = mCenterPoint.X - mCirCleRadius;
    mPointsArray[10].Y = mCenterPoint.Y + (M * mCirCleRadius);
}

水滴状效果状态分解

状态一:圆点

在这里插入图片描述

状态二和四:锥形

在这里插入图片描述

其中状态四为向左拉伸。

状态二下的锥形绘制,可以理解为将右边数据组(p2-p4)整体右移了一段距离,这个距离是多少,是个问题,我觉得也是个坑?

状态四类似,就是左边数据组(p8 -> p10)整体左移了一段距离。

状态三:椭圆

在这里插入图片描述

整个椭圆的绘制有多种方式,这里是将M值变大,然后左右两边数据组整体往两边平移。

M变大的数值和平移的值也是需要考量的。

水滴状效果Android设计与实现

跟据上述的状态分解,可以将贝塞尔曲线简单的做如下计算:

方案A :

  1. 滑动比例0->0.4时,右边数据组原坐标不断加上 百分比*两圆宽
  2. 滑动比例0.4->0.6时,中心点原坐标不断加上百分比*两圆宽,绘制出一个椭圆型,偏移量为0.25两圆宽,M值变大。
  3. 滑动比例0.6->1时,中心点原坐标为下一点坐标,并不断减去 百分比*两圆宽

方案B :
在滑动比例0.4->0.6时,中心点左边数据组从0->0.35点两圆宽,右边数据组从0.5圆宽+半径不断移动到下一圆位置

这样的效果会有一个突兀的转变过程,是后期需要修改和重新设计考量的。

    private void calculateBezierPathCircle() {
        // 计算中心点坐标及控制点和数据点坐标
        mCenterPoint.X = mFixedCirclesList.get(mCurrentPosition).p.X;
        calculateFixedCirCleCoordinatesData();
        // 当页面滑动百分比小于0.4时,只更新右边组(2 -> 4)控制圆的点坐标
        if (mPercent < 0.4) {
            M = 0.551915024494f;  //M值不变
            // 如果圆滑动比例长度,没有超过半径,不绘制贝塞尔曲线
            if (mCircleItemWidth * mPercent > mCirCleRadius) {
                mPointsArray[2].X = mCenterPoint.X + mCircleItemWidth * mPercent;
                mPointsArray[3].X = mCenterPoint.X + mCircleItemWidth * mPercent;
                mPointsArray[4].X = mCenterPoint.X + mCircleItemWidth * mPercent;
            }
        } else if (mPercent < 0.6) { // 当滑动百分比大于0.4,小于0.6时,更新贝塞尔圆状态为椭圆
            M = 0.751915024494f;
            // 更新中心点坐标及控制点和数据点坐标
            mCenterPoint.X = mFixedCirclesList.get(mCurrentPosition).p.X + (mPercent * mCircleItemWidth);
            calculateFixedCirCleCoordinatesData();
//            // 绘制椭圆 8->10
//            mPointsArray[8].X = mCenterPoint.X - (mCircleItemWidth * 0.25F);
//            mPointsArray[9].X = mCenterPoint.X - (mCircleItemWidth * 0.25F);
//            mPointsArray[10].X = mCenterPoint.X - (mCircleItemWidth * 0.25F);
//            // 绘制椭圆 2->4
//            mPointsArray[2].X = mCenterPoint.X + (mCircleItemWidth * 0.25F);
//            mPointsArray[3].X = mCenterPoint.X + (mCircleItemWidth * 0.25F);
//            mPointsArray[4].X = mCenterPoint.X + (mCircleItemWidth * 0.25F);
            mPointsArray[8].X = mFixedCirclesList.get(mCurrentPosition).p.X - mCirCleRadius + (mCircleItemWidth * (mPercent - 0.4F) * (0.35F / 0.2F));
            mPointsArray[9].X = mFixedCirclesList.get(mCurrentPosition).p.X - mCirCleRadius + (mCircleItemWidth * (mPercent - 0.4F) * (0.35F / 0.2F));
            mPointsArray[10].X = mFixedCirclesList.get(mCurrentPosition).p.X - mCirCleRadius + (mCircleItemWidth * (mPercent - 0.4F) * (0.35F / 0.2F));

            mPointsArray[2].X = (float) (mFixedCirclesList.get(mCurrentPosition).p.X + 0.75F * mCircleItemWidth + ((0.25F * mCircleItemWidth) * ((mPercent - 0.4F) * 5.0F))) + mCirCleRadius;
            mPointsArray[3].X = (float)  (mFixedCirclesList.get(mCurrentPosition).p.X + 0.75F * mCircleItemWidth + ((0.25F * mCircleItemWidth) * ((mPercent - 0.4F) * 5.0F))) + mCirCleRadius;
            mPointsArray[4].X = (float)  (mFixedCirclesList.get(mCurrentPosition).p.X + 0.75F * mCircleItemWidth + ((0.25F * mCircleItemWidth) * ((mPercent - 0.4F) * 5.0F))) + mCirCleRadius;

        } else if (mPercent < 1) {
            M = 0.551915024494f;
            // 更新中心点坐标及控制点和数据点坐标
            mCenterPoint.X = mFixedCirclesList.get(mCurrentPosition).p.X + (1.0F * mCircleItemWidth);
            calculateFixedCirCleCoordinatesData();
            // 当滑动百分比大于0.8时,只更新左边组(8 -> 10)控制圆的点坐标
            // 左边组横坐标从0.6 * itemWidth 开始滑动,如果左边组横坐标小于定圆坐标不再绘制贝塞尔曲线圆
            if ((1 - mPercent) * mCircleItemWidth > mCirCleRadius) {
                mPointsArray[8].X = mCenterPoint.X - (mCircleItemWidth * (1 - mPercent));
                mPointsArray[9].X = mCenterPoint.X - (mCircleItemWidth * (1 - mPercent));
                mPointsArray[10].X = mCenterPoint.X - (mCircleItemWidth * (1 - mPercent));
            }
        }
    }

以下是贝塞尔曲线圆的绘制:

/**
 * 绘制贝塞尔曲线
 *
 * @param canvas
 */
private void drawBezierPathCircle(Canvas canvas) {
    // 路径重置
    mPath.reset();
    //0
    mPath.moveTo(mPointsArray[0].X, mPointsArray[0].Y);
    //0-3
    mPath.cubicTo(mPointsArray[1].X, mPointsArray[1].Y, mPointsArray[2].X, mPointsArray[2].Y, mPointsArray[3].X, mPointsArray[3].Y);
    //3-6
    mPath.cubicTo(mPointsArray[4].X, mPointsArray[4].Y, mPointsArray[5].X, mPointsArray[5].Y, mPointsArray[6].X, mPointsArray[6].Y);
    //6-9
    mPath.cubicTo(mPointsArray[7].X, mPointsArray[7].Y, mPointsArray[8].X, mPointsArray[8].Y, mPointsArray[9].X, mPointsArray[9].Y);
    //9-0
    mPath.cubicTo(mPointsArray[10].X, mPointsArray[10].Y, mPointsArray[11].X, mPointsArray[11].Y, mPointsArray[0].X, mPointsArray[0].Y);
    // 绘制曲线
    canvas.drawPath(mPath, mPaint);
}

以上就是水滴状效果的Android实现,参考Blog:https://blog.csdn.net/qq_24531461/article/details/63250250

待改善的地方

整个水滴效果仍然有很突兀的地方,这和不同状态的点控制和中间滑动状态的方案有关,另外网上的效果包含回弹,这可能会在后期进行完善。

但无论咋样,自己手敲的代码,以及时隔两年在写blog,还是希望大家多多包容~多有技术交流。
项目核心代码:
GitHub: https://github.com/ArtsSTAR/ViewPager2Navigation

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值