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)的参数
到此基本上吸附式效果的核心状态实现就结束了,但怎么让页面滑动比例转化为动圆的移动变化呢?
我使用了ViewPager2
中onPageScrolled( 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 :
- 滑动比例0->0.4时,右边数据组原坐标不断加上 百分比*两圆宽
- 滑动比例0.4->0.6时,中心点原坐标不断加上百分比*两圆宽,绘制出一个椭圆型,偏移量为0.25两圆宽,M值变大。
- 滑动比例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