在UI Movement上看到一个好看的下拉刷新的控件效果,想着把他实现了.
效果预览
原地址在UI Movement
效果是这样的
下面是我做出来的效果
效果分析
老样子,首先进行效果分析
- 这是一个下拉刷新控件
- 下拉的过程中从顶部出现很多圆形小点,小点的半径和亮度(透明度)会发生变化,这个变化是和下拉程度无关的.
- 随着下拉的进行,小圆点汇聚成一个圆环
- 下拉结束时,汇聚成的圆环的半径会形成类似于呼吸灯的大小变化.
- 下拉效果是有阻力的,也就是手指移动的距离和控件移动的距离是不一样的.
- 在没有下拉形成一个完整圆环的时候松手,小点会散回去
我分了两步来实现这个控件
- 下拉的动画,他负责实现具体的动画效果
- 下拉控件布局,负责处理触摸事件,对外暴露接口
也就是说这个这个下拉刷新控件是由一个动画和自定义的viewGroup组合而成的.
下拉动画的实现
我把它叫做HaloRingAnimation
,这是一个自定义view.
根据效果分析,需要定义一个点,起始坐标,结束坐标,移动路径,半径,透明度.我专门定义了一个类HaloPoint
private class HaloPoint {
float pos[] = {0, 0};//坐标,x,y;
float radius = 8;//点的半径
Path mPath;//移动路径
float length;//路径长度
float endPos[] = {0, 0};//终点位置坐标
float startPercent = 0f;//可以显示时的比例,确定何时出现
boolean drawAble = false;//确定是否可现实
int alpha;//透明度
void setPos(float x, float y) {
this.pos[0] = x;
this.pos[1] = y;
}
void setRadius(float radius) {
this.radius = radius;
}
}
在进行初始化的时候,每个点都需要进行初始化,一个圆环360度,可以360个点组成。
每个点的初始位置都在控件的最上层,也就是每个点的初始y坐标为-5(为了出现的效果不那么突兀),而x坐标,我决定使用随机值,每个点的大小,透明度,移动我也使用了随机值.
for (int i = 0; i < 360; i++) {
HaloPoint haloPoint = mHaloPoints.get(i);
haloPoint.setRadius((float) (1 + Math.random() * 3));//初始化点的大小
setStartPos((float) (Math.random() * width), -5, haloPoint.pos);//初始化点的位置
setEndPos(width / 2, mRingTop + mRingRadius, -90 + i, haloPoint.endPos);//初始化点移动的结束位置
haloPoint.mPath = setPath(haloPoint.pos, haloPoint.endPos);//初始化点移动的路径path
pathMeasure.setPath(haloPoint.mPath, false);//将path进行测量
haloPoint.length = pathMeasure.getLength();//获取path的总长度
haloPoint.startPercent = i > 180 ? (1 - (i / 360.0f)) : mHalfPercent / 180 * i;//设置point应该何时出现
haloPoint.alpha = 100 + (int) (50 * Math.random());//点的变化透明度
}
这里面有两个地方需要说一下
一是点的最终坐标的确定,这些点的坐标要保证能够形成一个圆
//确定点移动结束后在圆上的坐标,
private void setEndPos(int xDot, int yDot, int radius, float pos[]) {
pos[0] = (float) (xDot + mRingRadius * (Math.cos(radius * Math.PI / 180)));
pos[1] = (float) (yDot + mRingRadius * (Math.sin(radius * Math.PI / 180)));
}
二是点的移动路径,点的移动路径使用了path,曲线使用的两段二阶的贝塞尔曲线构成的.而且贝塞尔曲线的取点,也选的是随机的,只有终点位置是确定的,不过也可以通过设置不同的曲线来实现不同的效果.
//设置点的移动路径
private Path setPath(float startPos[], float endPos[]) {
Path path = new Path();
path.moveTo(startPos[0], startPos[1]);
path.quadTo((float) (Math.random() * width), (float) (Math.random() * mRingTop), (float) width / 4 + (float) (Math.random() * width / 2), (float) (Math.random() * mRingTop));
path.quadTo((float) (Math.random() * width), (float) (Math.random() * mRingTop), endPos[0], endPos[1]);
return path;
}
为了实现点的大小变化和下拉无关,使用了一个ValueAnimator
,用来产生有规律的数字,进行主动刷新重绘
mPointAlphaRadiusValueAnimator = new ValueAnimator();
mPointAlphaRadiusValueAnimator.setDuration(800);
mPointAlphaRadiusValueAnimator.setFloatValues(100);
mPointAlphaRadiusValueAnimator.setRepeatCount(ValueAnimator.INFINITE);//动画无限进行,
mPointAlphaRadiusValueAnimator.setRepeatMode(ValueAnimator.REVERSE);//动画的循环方式
mPointAlphaRadiusValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
timeRationAlpha = (float) animation.getAnimatedValue();//透明度变化
timeRationRadius = (float) animation.getAnimatedValue() * 0.05f;//圆点半径的变化
mRingRadius = (int) ((float) animation.getAnimatedValue() * 0.1f + mRingRadiusONLY - 5);//圆环半径的变化
invalidate();//重绘
}
});
可以看到我是在这里进行重绘的.
看一下重要的绘制部分
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (!mPullEnd) {//在没有下拉到底部的时候才进行绘制
for (int i = 0; i < 360; i += 5) {//调整i的间隔数可以实现不同的效果
HaloPoint haloPoint = mHaloPoints.get(i);
haloPoint.drawAble = haloPoint.startPercent < mPercent;
//将需要显示的点显示出来
if (!haloPoint.drawAble)
continue;
//确定点的位置
pathMeasure.setPath(haloPoint.mPath, false);
pathMeasure.getPosTan(haloPoint.length * (mPercent - haloPoint.startPercent) / mHalfPercent, haloPoint.pos, null);
int alpha = Math.min((int) (haloPoint.alpha + i + timeRationAlpha), 255);
mPointPaint.setAlpha(alpha);
float pointRadius;
//产生不同点在不同时刻大小不断变化的效果
if (i % 2 == 0)
pointRadius = (float) (timeRationRadius * ((Math.cos((mPercent - haloPoint.startPercent) / mHalfPercent * 100))) + 6);
else
pointRadius = (float) (timeRationRadius * (Math.abs(Math.sin((mPercent - haloPoint.startPercent) / mHalfPercent * 100))) + 4);
//当点移动到位时,透明度,大小还有阴影都不再发生变化
if ((mPercent - haloPoint.startPercent) / mHalfPercent >= 1) {
pointRadius = 7;
mPointPaint.setAlpha(255);
mPointPaint.clearShadowLayer();
}
canvas.drawCircle(haloPoint.pos[0], haloPoint.pos[1], pointRadius, mPointPaint);
}
} else {
//当下拉底部时,画出圆环
canvas.drawCircle(width / 2, mRingTop + mRingRadiusONLY, mRingRadius, mRingPaint);
}
}
这里面重要的一步是如何确定点的位置,也就是确定确定path上某一点的位置,有pathMeasure.getPosTan()
这个方法可以用,需要传入三个参数,该点在path上距起点的距离,保存点坐标的一个数组,保存正切值的一个数组.这个距离也可以通过pathMeasure.getLength()
来获得.
下拉控件的实现
通过继承FrameLayout,对其进行改写,这一部分参考了官方的SwipeRefreshLayout的实现,还有CircleRefreshLayout的实现.学习到了很多
FrameLayout的布局是最简单的一种布局,就是所有的控件都放在左上角,进行层叠摆放,这也正是我需要的,也就没有对其重写.
真正重写地方有3处
1.addview
,为了保证控件中只有一个子view
@Override
public void addView(View child) {
//确保只能添加一个view
if (getChildCount() >= 1) {
throw new RuntimeException("you can only attach one child");
}
mChildView = child;
super.addView(child);
}
2.onInterceptTouchEvent
,为了保证触摸事件正确地分发
public boolean onInterceptTouchEvent(MotionEvent event) {
if (mIsRefreshing||!mEnableRefresh) {
return super.onInterceptTouchEvent(event);
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mIsRefreshing = false;
mInitialDownY = event.getY();
mIsBeingDragged = false;
mHeaderView.setEndpull(false);
break;
case MotionEvent.ACTION_MOVE:
float y = event.getY();
startDragging(y);//判断是否产生有效滑动
if (mIsBeingDragged)
return true;
}
return super.onInterceptTouchEvent(event);
}
3.onTouchEvent
,根据触摸效果进行处理.
@Override
public boolean onTouchEvent(MotionEvent event) {
if (mIsRefreshing) {
return super.onTouchEvent(event);
}
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_MOVE:
float y = event.getY();
startDragging(y);
if (mIsBeingDragged) {
//加入滑动阻力,计算出控件应该移动的距离
overScrollTop = (y - mInitialMotionY) * dragRatio;
if (overScrollTop > 0) {
fingerMove(overScrollTop);
mHeaderView.setPercent(mPercent);
if (overScrollTop < mMaxDragDistance) {
if (!mHeaderView.isAnimationRunning())
mHeaderView.startAnimation();
//子view进行移动
mChildView.setTranslationY(overScrollTop);
}
} else {
return false;
}
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mIsBeingDragged = false;
//根据不同的滑动程度来确定不同的手指离开后的效果
if (mPercent == 1) {
mPullEnd = true;
mIsRefreshing = true;
mHeaderView.setEndpull(true);
mHeaderView.setAnimationCurrentPlayTime(400);
} else {//当没有拉到底部时,将控件返回
mPullEnd = false;
mIsRefreshing = false;
mHeaderView.setEndpull(false);
mBackUpValueAnimator.setCurrentPlayTime((long) ((1 - mPercent) * BACK_UP_DURATION));
mBackUpValueAnimator.start();
}
break;
}
return true;
}
实现起来其实没有那么难, 不过也能从中学习到不少的东西.
源代码地址在我的https://github.com/Mran/HaloRingPullRefresh,欢迎start和issue.
水平不足,说错的地方请多多指教.