本文已授权微信公众号:鸿洋(hongyangAndroid)原创首发。
转载请标明出处:
http://blog.csdn.net/qian520ao/article/details/68952079
本文出自凶残的程序员的博客
前些天看到这个效果图
效果真是酷炫极了,感觉很biu踢,咱们说做就做。
[改装加强版,改进了圆入框的甩尾效果,最重要的一点是增加了美女卡片!增强ViewPager切换效果和卡片阴影]
github地址 : https://github.com/qdxxxx/BezierViewPager
多谢老铁随手就是一个star,抱拳。
[标题党一般是: 转疯了,项目集成此酷炫动画只要3步!]
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
dependencies {
compile 'com.github.qdxxxx:BezierViewPager:v1.0.2'
}
.bezierviewpager_compile.vPage.BezierViewPager
android:id="@+id/view_page"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
.bezierviewpager_compile.BezierRoundView
android:id="@+id/bezRound"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
CardPagerAdapter cardAdapter = new CardPagerAdapter(getApplicationContext());
cardAdapter.addImgUrlList(imgList); //放置图片url的list
BezierViewPager viewPager = (BezierViewPager) findViewById(R.id.view_page);
viewPager.setAdapter(cardAdapter);
BezierRoundView bezRound = (BezierRoundView) findViewById(R.id.bezRound);
bezRound.attach2ViewPage(viewPager);
name | format | 中文解释 |
---|---|---|
color_bez | color | 贝塞尔圆球颜色 |
color_touch | color | 触摸反馈 |
color_stroke | color | 圆框的颜色 |
time_animator | integer | 动画时间 |
round_count | integer | 圆框数量,即Adapter.getCount |
radius | dimension | 贝塞尔圆球半径,圆框半径为(radius-2) |
attach2ViewPage | BezierViewPager | 绑定指定的ViewPager(处理滑动时触摸事件) 并自动设置round_count |
name | format | 中文解释 |
---|---|---|
showTransformer | float | ViewPager滑动到当前显示页的放大比例 |
name | format | 中文解释 |
---|---|---|
addImgUrlList | List | 包含图片地址的list |
setOnCardItemClickListener | OnCardItemClickListener | 当前ViewPager点击事件 返回CurPosition |
setMaxElevationFactor | integer | Adapter里CardView最大的Elevation |
首先,我们需要绘制P0,然后 cubicTo p1,p2,p3,再cubicTo p4,p5.p6……
原谅我用这么简单粗暴的方式画圆…
private PointF p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11;
p0 = new PointF(0, -mRadius);//mRadius圆的半径
p6 = new PointF(0, mRadius);
p1 = new PointF(mRadius * bezFactor, -mRadius);//bezFactor即0.5519...
p5 = new PointF(mRadius * bezFactor, mRadius);
p2 = new PointF(mRadius, -mRadius * bezFactor);
p4 = new PointF(mRadius, mRadius * bezFactor);
p3 = new PointF(mRadius, 0);
p9 = new PointF(-mRadius, 0);
p11 = new PointF(-mRadius * bezFactor, -mRadius);
p7 = new PointF(-mRadius * bezFactor, mRadius);
p10 = new PointF(-mRadius, -mRadius * bezFactor);
p8 = new PointF(-mRadius, mRadius * bezFactor);
再绘制path
mPath.moveTo(p0.x, p0.y);
mPath.cubicTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
mPath.cubicTo(p4.x, p4.y, p5.x, p5.y, p6.x, p6.y);
mPath.cubicTo(p7.x, p7.y, p8.x, p8.y, p9.x, p9.y);
mPath.cubicTo(p10.x, p10.y, p11.x, p11.y, p0.x, p0.y);
mPath.close();
我们尝试通过手指滑动改变,p2,p3,p4的x轴坐标来观察圆的变化
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
case MotionEvent.ACTION_DOWN:
p2 = new PointF(event.getX() - mWidth / 2, -mRadius * bezFactor);
p3 = new PointF(event.getX() - mWidth / 2, 0);
p4 = new PointF(event.getX() - mWidth / 2, mRadius * bezFactor);
invalidate();
break;
}
return true;
}
老样子,我们用ValueAnimator来模拟一下[0,1]变化的值。【因为ViewPager的onPageScrolled监听中positionOffset是[0,1)变化的,类似。】
惊!下面几段代码居然!男的看了沉默,女的看了流泪。
//展示动画
private ValueAnimator animatorStart;
private TimeInterpolator timeInterpolator = new DecelerateInterpolator();
private float animatedValue; //[0,1]的值
public void startAnimator() {
if (animatorStart != null) {
if (animatorStart.isRunning()) {
return;
}
animatorStart.start();
} else {
animatorStart = ValueAnimator.ofFloat(0, 1f).setDuration(1500);
animatorStart.setInterpolator(timeInterpolator);
animatorStart.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
animatedValue = (float) animation.getAnimatedValue();
invalidate();
}
});
animatorStart.start();
}
}
private float rRadio=1; //P2,3,4 x轴倍数
private float lRadio=1; //P8,9,10倍数
private float tbRadio=1; //y轴缩放倍数
private float disL = 0.5f; //离开圆的阈值
private float disM = 0.8f; //最大值的阈值
private float disA = 0.9f; //到达下个圆框的阈值
if (0 < animatedValue && animatedValue <= disL) { //还没离开圆框的时候
rRadio = 1f + animatedValue * 2; //[1,2]
}
if (disL < animatedValue && animatedValue <= disM) {//离开圆框,至最大值区域
rRadio = 2 - range0Until1(disL, disM) * 0.5f; // [2,1.5]
lRadio = 1 + range0Until1(disL, disM) * 0.5f; // [1,1.5]
}
if (disM < animatedValue && animatedValue <= disA) { //从最大值,至到达下一个圆框
rRadio = 1.5f - range0Until1(disM, disA) * 0.5f; // [1.5,1]
lRadio = 1.5f - range0Until1(disM, disA) * 0.5f; // [1.5,1]
}
/**
* 将值域转化为[0,1]
*
* @param minValue 大于等于
* @param maxValue 小于等于
* @return 根据当前 animatedValue,返回 [0,1] 对应的数值
*/
private float range0Until1(float minValue, float maxValue) {
return (animatedValue - minValue) / (maxValue - minValue);
}
请再次原谅我用这么简单粗暴的方式画圆…
mPath.moveTo(p0.x, p0.y * tbRadio);
mPath.cubicTo(p1.x, p1.y * tbRadio, p2.x * rRadio, p2.y, p3.x * rRadio, p3.y);
mPath.cubicTo(p4.x * rRadio, p4.y, p5.x, p5.y * tbRadio, p6.x, p6.y * tbRadio);
mPath.cubicTo(p7.x, p7.y * tbRadio, p8.x * lRadio, p8.y, p9.x * lRadio, p9.y);
mPath.cubicTo(p10.x * lRadio, p10.y, p11.x, p11.y * tbRadio, p0.x, p0.y * tbRadio);
mPath.close();
理清了上面这些代码,一个有灵性的贝塞尔圆就即将绘制成功。我们再加上离开圆至到达下一个圆框这个区域y轴变化,[p,5,6,7, 1,0,11],效果就如下所示。
这时候我们已经将贝塞尔圆的运动方式给表达出来了,再加上一些效果[位移/反弹/翻转],我们就能模拟出贝塞尔圆从一个圆框进入下一个圆框的动画了。
在上面的基础上,我们加上反弹效果
if (0 < animatedValue && animatedValue <= disL) { //还没离开圆框的时候
rRadio = 1f + animatedValue * 2; //[1,2]
}
if (disL < animatedValue && animatedValue <= disM) {//离开圆框,至最大值区域
rRadio = 2 - range0Until1(disL, disM) * 0.5f; // [2,1.5]
lRadio = 1 + range0Until1(disL, disM) * 0.5f; // [1,1.5]
tbRadio = 1 - range0Until1(disL, disM) / 3; // [1 , 2/3]
}
if (disM < animatedValue && animatedValue <= disA) { //从最大值,至到达下一个圆框
rRadio = 1.5f - range0Until1(disM, disA) * 0.5f; // [1.5,1]
lRadio = 1.5f - range0Until1(disM, disA) * (1.5f - boundRadio); //反弹效果,进场 内弹boundRadio lRadio =[1.5,boundRadio]
tbRadio = (range0Until1(disM, disA) + 2) / 3; // [ 2/3,1]
}
if (disA < animatedValue && animatedValue <= 1f) {//到达圆框,lRadio=[boundRadio,1]
rRadio = 1;
tbRadio = 1;
lRadio = boundRadio + range0Until1(disA, 1) * (1 - boundRadio); //反弹效果,饱和
}
再加上位移效果。一开始我在想,贝塞尔圆要不断的变化形态,还要移动位置。岂不相当的麻烦。后来把它分解成变化状态+不断位移效果。
boolean isTrans = false;
float transX = 1f;
if (disL <= animatedValue && animatedValue <= disA) { //离开圆框,至到达下一个圆框
isTrans = true;
//我们设置2个圆框距离为mWidth / 2f
transX = mWidth / 2f * range0Until1(disL, disA); //[0,mWidth / 2f]
}
if (disA < animatedValue && animatedValue <= 1) {//到达下一个圆
isTrans = true;
transX = mWidth / 2;
}
if (isTrans) {
canvas.translate(transX, 0);
}
至此贝塞尔圆球进入右侧圆框的效果已经实现,那么如果圆球要从右侧圆框进入左侧圆框呢?
【题外话:写完上面这个效果已经是月黑风高的时候了,脑神经即将进入假死状态,我心想,虽然复杂了点,但是应该还是可以做的出来的,脑袋运行的速度根本跟不上敲代码的速度。根据位移方向的判断从而设定lRadio和rRadio。有点自信回头的赶脚。。。休息了一觉第二天醒来天啊噜,为什么不用Matrix,只要用path.transform(matrix),就可以做到镜像path,所以适当的休息有助于提升效率。】
matrix_bounceL = new Matrix();
matrix_bounceL.preScale(-1, 1);
mPath.transform(matrix_bounceL);
关联ViewPager总共有2个要点
首先我们来了解一下onPageScrolled
这个方法中2个我们要用到的参数
我们功能需求分析一下:
之前我们用ValueAnimator
来模拟运动状态,现在我们可以使用positionOffset
关联到ViewPager
animatedValue = positionOffset;
direction = ((position + positionOffset) - curPos > 0); //运动方向。 true为右边(手往左滑动)
nextPos = direction ? curPos + 1 : curPos - 1; //右 +1 左 -1
if (!direction) //如果是向左
animatedValue = 1 - animatedValue; //让 animatedValue 不管是左滑还是右滑,都从[0,1)开始计算
if (positionOffset == 0) {
curPos = position;
nextPos = position;
}
以上代码还需动手调试,看看log才能更明白的领悟。
从上面的gif可以发现如果缓慢的滑动,pos的位置正确的,但是如果快速滑动,就会发现问题 : [例如0快速滑动到2,贝塞尔圆球会从0滑动到1,再从0滑动到2],打了Log之后我们才发现原来快速滑动的时候,positionOffset到达下一个pos不会置为0!!发现问题后就好解决了。我们加上这一段代码就可以解决该问题。(快速滑动可能存在或多或少的问题,我也是花了些时间去测试的。)
//快速滑动的时候,positionOffset有可能不会置于0
if (direction && position + positionOffset > nextPos) { //向右,而且
curPos = position;
nextPos = position + 1;
} else if (!direction && position + positionOffset < nextPos) {
curPos = position;
nextPos = position - 1;
}
onDraw
我们先要获得每个圆框的圆心x轴坐标
private float[] bezPos; //记录每一个圆心x轴的位置
bezPos = new float[default_round_count]; //根据圆框个数
for (int i = 0; i < default_round_count; i++) {
bezPos[i] = mWidth / (default_round_count + 1) * (i + 1);
}
假设我们的default_round_count 即圆框个数为4,那么我们就要分成 4+1 份,再综合上述的求圆心代码,应该会更清晰一点。
根据curPos和nextPos绘制贝塞尔圆球,po出onDraw代码
canvas.translate(0, mHeight / 2);
mBezPath.reset();
for (int i = 0; i < default_round_count; i++) {
canvas.drawCircle(bezPos[i], 0, mRadius - 2, mRoundStrokePaint); //绘制圆框
}
if (animatedValue == 1) {
canvas.drawCircle(bezPos[nextPos], 0, mRadius, mBezPaint);
return;
}
canvas.translate(bezPos[curPos], 0); //根据curPos,移动到当前圆框位置
if (0 < animatedValue && animatedValue <= disL) {
rRadio = 1f + animatedValue * 2; // [1,2]
lRadio = 1f;
tbRadio = 1f;
}
if (disL < animatedValue && animatedValue <= disM) {
rRadio = 2 - range0Until1(disL, disM) * 0.5f; // [2,1.5]
lRadio = 1 + range0Until1(disL, disM) * 0.5f; // [1,1.5]
tbRadio = 1 - range0Until1(disL, disM) / 3; // [1 , 2/3]
}
if (disM < animatedValue && animatedValue <= disA) {
rRadio = 1.5f - range0Until1(disM, disA) * 0.5f; // [1.5,1]
lRadio = 1.5f - range0Until1(disM, disA) * (1.5f - boundRadio); //反弹效果,进场 内弹boundRadio
tbRadio = (range0Until1(disM, disA) + 2) / 3; // [ 2/3,1]
}
if (disA < animatedValue && animatedValue <= 1f) {
rRadio = 1;
tbRadio = 1;
lRadio = boundRadio + range0Until1(disA, 1) * (1 - boundRadio); //反弹效果,饱和
}
if (animatedValue == 1 || animatedValue == 0) { //防止极其粗暴的滑动
rRadio = 1f;
lRadio = 1f;
tbRadio = 1f;
}
boolean isTrans = false; //根据nextPos和curPos求出位移距离
float transX = (nextPos - curPos) * (mWidth / (default_round_count + 1));
if (disL <= animatedValue && animatedValue <= disA) {
isTrans = true;
transX = transX * (animatedValue - disL) / (disA - disL);
}
if (disA < animatedValue && animatedValue <= 1) {
isTrans = true;
}
if (isTrans) {
canvas.translate(transX, 0);
}
mBezPath.moveTo(p0.x, p0.y * tbRadio);
mBezPath.cubicTo(p1.x, p1.y * tbRadio, p2.x * rRadio, p2.y, p3.x * rRadio, p3.y);
mBezPath.cubicTo(p4.x * rRadio, p4.y, p5.x, p5.y * tbRadio, p6.x, p6.y * tbRadio);
mBezPath.cubicTo(p7.x, p7.y * tbRadio, p8.x * lRadio, p8.y, p9.x * lRadio, p9.y);
mBezPath.cubicTo(p10.x * lRadio, p10.y, p11.x, p11.y * tbRadio, p0.x, p0.y * tbRadio);
mBezPath.close();
if (!direction) {
mBezPath.transform(matrix_bounceL);
}
canvas.drawPath(mBezPath, mBezPaint);
if (isTrans) {
canvas.save();
}
我们需要判断是否点击到了圆框上,和点击了具体哪个圆框。
在onPageScrolled
方法的时候不进行处理,而是通过ValueAnimator
来模拟数值。从而绘制贝塞尔圆球效果。
private float[] xPivotPos; //根据圆心x轴+mRadius,划分成不同的区域 ,主要为了判断触摸x轴的位置
xPivotPos = new float[default_round_count];
for (int i = 0; i < default_round_count; i++) {
xPivotPos[i] = mWidth / (default_round_count + 1) * (i + 1) + mRadius;
}
针对x轴 : 我的做法是用一个数组xPivotPos 存储每个圆框最边缘的位置,即圆心+mRadius,然后我们触摸的时候,就可以找到当前触摸touchPos是属于哪个(圆框+mRadius)范围内。只要x >=bezPos[touchPos]-mRadius,就可以清楚的知道是否触摸到了该区域的圆框范围。
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
float x = event.getX();
float y = event.getY();
if (y <= mHeight / 2 + mRadius && y >= mHeight / 2 - mRadius && !isAniming) { //先判断y,如果y点击是在圆y轴的范围
int pos = -Arrays.binarySearch(xPivotPos, x) - 1;
if (pos >= 0 && pos < default_round_count && x + mRadius >= bezPos[pos]) {
nextPos = pos;
if (mViewPage != null && curPos != nextPos) {
mViewPage.setCurrentItem(pos);
isAniming = true;
direction = (curPos < pos);
startAnimator(); //我们通过ValueAnimator来模拟具体的值,不使用ViewPager的onPageScrolled方法。
}
}
return true;
}
break;
}
return super.onTouchEvent(event);
}
至此我们BezierRoundView的用法和绘制方法已经讲解完了,下面来看一下ViewPager是怎么实现切换效果的。
参考【https://github.com/rubensousa/ViewPagerCards】
【灵魂画家】
上图针对的是ViewPager设置Padding之后,setClipToPadding设置true,false不同的区别。
左图是正常情况下默认setClipToPadding(true)的显示情况,设置Padding之后,手机屏幕上只显示width-PaddingLeft - PaddingRight。
而如果设置setClipToPadding(false)情况,表示不裁剪Padding,这时候我们就可以看到左右的ViewPager,相当于原本两边的Padding透明度为1,而设置false之后透明度为0。
CardPagerAdapter是我们继承PagerAdapter
的类,adapter里的布局是cardView
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/cardView"
app:cardCornerRadius="10dp"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardPreventCornerOverlap="true"
app:cardUseCompatPadding="true">
<ImageView
android:id="@+id/item_iv"
android:scaleType="fitXY"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android.support.v7.widget.CardView>
先来了解一下cardView setCardElevation(float)
方法。【针对CardViewApi21】
if (!cardView.getUseCompatPadding()) {
cardView.setShadowPadding(0, 0, 0, 0);
return;
}
float elevation = getMaxElevation(cardView);
final float radius = getRadius(cardView);
int hPadding = (int) Math.ceil(RoundRectDrawableWithShadow
.calculateHorizontalPadding(elevation, radius, cardView.getPreventCornerOverlap()));
int vPadding = (int) Math.ceil(RoundRectDrawableWithShadow
.calculateVerticalPadding(elevation, radius, cardView.getPreventCornerOverlap()));
cardView.setShadowPadding(hPadding, vPadding, hPadding, vPadding);
static float calculateVerticalPadding(float maxShadowSize, float cornerRadius,
boolean addPaddingForCorners) {
if (addPaddingForCorners) {
return (float) (maxShadowSize * SHADOW_MULTIPLIER + (1 - COS_45) * cornerRadius);
} else {
return maxShadowSize * SHADOW_MULTIPLIER;
}
}
static float calculateHorizontalPadding(float maxShadowSize, float cornerRadius,
boolean addPaddingForCorners) {
if (addPaddingForCorners) {
return (float) (maxShadowSize + (1 - COS_45) * cornerRadius);
} else {
return maxShadowSize;
}
}
下面看一下效果测试。
我们来看一下ViewPager左右设置Padding为mWidth / 10的效果
viewPager.setPadding(mWidth / 10, 0, mWidth / 10, 0);
viewPager.setClipToPadding(false);
再来看一下CardPagerAdapter设置MaxElevationFactor为mWidth / 10的效果【adapter.xml的cardCornerRadius不设值,cardUseCompatPadding一定要设置true!!】
int maxFactor = mWidth / 10;
cardAdapter.setMaxElevationFactor(maxFactor);
具体我也不赘述了,看图应该能分析出两者的不同。
所以现在综上所述,制定一个需求
也就是说当我们知道图片的宽高比例之后,代码里面我们要动态的去调整和设置并保持这个宽高比例。
【这边有个坑就是设置setMaxElevation它的宽高比是不可抗的,所以我们只能在setPadding的时候,去调节这个比例】
【setMaxElevation
宽的Padding为maxFactor + 0.3*CornerRadius 【0.3≈≈ (1 - COS_45)】
高的Padding为maxFactor*1.5f + 0.3*CornerRadius】
但是!鸡生的 如果我们在setMaxElevation
的情况下,在去设置padding,那么如何保证我们的宽高比?具体请看如下代码分析。【可以通过去掉adapter.xml 里ImagerView 的android:scaleType=”fitXY”属性测试一下宽高比例是否调试正确
】
//已知图片的宽为1920,高1080.
int mWidth = getWindowManager().getDefaultDisplay().getWidth();
float heightRatio = 0.565f; //高是宽的 0.565 ,根据图片比例
CardPagerAdapter cardAdapter = new CardPagerAdapter(getApplicationContext());
cardAdapter.addImgUrlList(imgList);//添加加载的图片集合
//设置阴影大小,即vPage 左右两个图片相距边框 maxFactor + 0.3*CornerRadius *2
//设置阴影大小,即vPage 上下图片相距边框 maxFactor*1.5f + 0.3*CornerRadius
int maxFactor = mWidth / 25;
cardAdapter.setMaxElevationFactor(maxFactor);
int mWidthPading = mWidth / 8;
//因为我们adapter里的cardView CornerRadius已经写死为10dp,所以0.3*CornerRadius=3
//设置Elevation之后,控件宽度要减去 (maxFactor + dp2px(3)) * heightRatio
//heightMore 设置Elevation之后,控件高度 比 控件宽度* heightRatio 多出的部分
float heightMore = (1.5f * maxFactor + dp2px(3)) - (maxFactor + dp2px(3)) * heightRatio;
int mHeightPading = (int) (mWidthPading * heightRatio - heightMore);
BezierViewPager viewPager = (BezierViewPager) findViewById(R.id.view_page);
viewPager.setLayoutParams(new RelativeLayout.LayoutParams(mWidth, (int) (mWidth * heightRatio)));
viewPager.setPadding(mWidthPading, mHeightPading, mWidthPading, mHeightPading);
viewPager.setClipToPadding(false);
viewPager.setAdapter(cardAdapter);
改方法是设置ViewPager移动的时候,cardView放大效果和Elevation阴影效果,具体过程可以自行在ShadowTransformer
查看,实现过程上文基本也有覆盖。
零零碎碎也捣鼓了一阵子的自定义View,我在想既然迈出这一步了,就得做好它。
人生总是要有信仰,有梦想才能一直前行,哪怕走的再慢,也是在前行。
如果这篇文章写的还凑合或者勾引起了你的斗志的话,欢迎点个star
https://github.com/qdxxxx/BezierViewPager