文章首发于我的个人博客:www.huangjie.name,欢迎关注!共同学习,共同进步
昨天在慕课上看到了一个自定义粘性头部的实现,发现现在有许多的app都有这个效果,于是跟着慕课上得视频敲了一遍。因为慕课上的老师对于后半部分的坐标运算讲的并不是很细致,因此我自己分析了一下源代码,总结了这篇博客,希望对同学们有所帮助。
慕课视频地址:https://www.imooc.com/learn/830
源码下载地址:https://github.com/Jay-huangjie/TouchPullView
建议大家先下载源码再来观看
用到的知识点
我们先来复习一下需要用到的数学知识点
三角函数:
* sin = a/c;
* cos = b/c;
* tan = a/b;
贝塞尔曲线:
阅读本文需要了解自定义View的基本流程和贝塞尔曲线的绘制。
好,进入正题,我们先看一下它的运行效果:
emmmmm,效果感觉还不错~
实现原理
既然是拖动,肯定是基于Touch事件来实现的,通过Touch的Y坐标获取到拖动进度progress,然后通过requestLayout方法不断重绘界面,在onSizeChanged方法中通过计算不断移动圆心坐标,圆的左右两边是经典的贝塞尔曲线,只要获取到控制点和结束点的坐标就能绘制出来,通过控制点的不断移动和onDraw方法中的画布的不断移动来达到弹性和顶部两个起始点向中心靠拢的效果,中间的圆心与旁边的间距部分则是使用的drawable的Bounds效果,回弹则是利用的属性动画的addUpdateListener接口将progress数值由大到小执行回去从而达到的回弹效果
具体分析
光看原理肯定是四脸蒙蔽,还是结合代码分析才能达到事半功倍的效果。
我们先看OnTouch部分的代码:
findViewById(R.id.ll_mainLayout).setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
int actionMasked = event.getActionMasked();
switch (actionMasked) {
case MotionEvent.ACTION_DOWN:
mTouchStartY = event.getY();
return true;
case MotionEvent.ACTION_MOVE:
float y = event.getY();
if (y >= mTouchStartY) { //表示向下移动
float moveSize = y - mTouchStartY;
float progress = moveSize > TOUCH_MOVE_MAX_Y ?
1 : moveSize / TOUCH_MOVE_MAX_Y; //计算进度值
touchView.setProgress(progress);
return true;
}
break;
case MotionEvent.ACTION_UP:
touchView.release();
return true;
default:
break;
}
return false;
}
});
这里还是比较简单,通过getY获取滑动的距离,按下去有一个点,滑动后有一个点,两点之间的距离就是我们的
moveSize了,由于progress是指当前滑动的进度值,取值肯定是在0~1之间,因此,当moveSize大于我们预设的最大值时,progress就返回1,否则就返回具体的进度值,最后在设置给touchView,touchView就会进行重绘。
再来看下TouchPullView里面的代码:
我们先看一下变量:
private Paint mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private float mCircleRadius = 50; //圆的半径
private float mCirclePointX, mCirclePointY; //圆心坐标
private int mDargHeight = 400; //最大可下拉的高度
private float mProgress; //下拉进度值
private int mTargetWidth = 400; //目标宽度
private Path mPath = new Path(); //贝塞尔路径
private Paint mPathPaint = new Paint(Paint.ANTI_ALIAS_FLAG); //贝塞尔画笔
private int mTargetGravityHeight = 10; //重心点最终高度,决定控制点的Y坐标
private int mTargetAngle = 105; //角度变换 0~135
private Interpolator mProgessInterpolator = new DecelerateInterpolator(); //一个由快到慢的插值器
private Interpolator mTanentAngleInterpolator;
private Drawable content = null; //中心圆drawable
private int mContentDrawableMargin = 0; //中心圆Drawable边距
大部分变量都简洁明了,mTargetGravityHeight规定了控制点上下浮动的最大距离,该值越大,控制点越往下移,mTargetAngle规定了结束点与圆心连线的那个角度的最大变幻值,因为不规定可以看到结束点会围绕圆心做圆环运动,最终导致变形,这不是我们希望看到的。mTargetWidth则规定了该控件能缩小的最小宽度。
接下来看onDraw()方法的实现:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//基础坐标系改变
int count = canvas.save();
//获取平移画布的X的值,随着下滑起始点的坐标移动
final float transX = (getWidth() - getValueByLine(getWidth(), mTargetWidth, mProgress)) / 2;
canvas.translate(transX, 0);
//绘制贝塞尔
canvas.drawPath(mPath, mPathPaint);
//画圆
canvas.drawCircle(mCirclePointX, mCirclePointY, mCircleRadius, mCirclePaint);
//绘制Drawable
Drawable drawable = content;
if (drawable != null) {
canvas.save();
canvas.clipRect(drawable.getBounds());
drawable.draw(canvas);
canvas.restore();
}
canvas.restoreToCount(count);
}
getValueByLine
方法的作用是获取某一时刻贝塞尔曲线上的点的坐标,看一下它的方法实现:
/**
* 获取某一时刻的值
*
* @param star 起始点
* @param end 结束点
* @param mProgress 当前进度值
* @return
*/
private float getValueByLine(float star, float end, float mProgress) {
return star + (end - star) * mProgress;
}
它就是一个贝塞尔曲线公式Bt = P0+(P1-P0)*t,传入初始坐标P0和结束坐标P1和时间t,就能获取到Bt了。
接着它得到了一个transX,它等于起始点与屏幕左右的距离,通过画布的移动来实现起始点不断靠拢的效果,接下来是绘制贝塞尔曲线,画圆,根据Bounds大小添加Drawable到画布上。
接下来分析我们的重点:onSizeChanged中的实现,关键是这个方法
/**
* 更新路径
*/
private void updatePathLayout() {
final float progress = mProgessInterpolator.getInterpolation(mProgress);
//获取所有的可绘制的宽/高 此值会根据progress不断的变化
final float w = getValueByLine(getWidth(), mTargetWidth, mProgress);
final float h = getValueByLine(0, mDargHeight, mProgress);
//圆心X坐标
final float cPointX = w / 2;
//半径
final float cRadius = mCircleRadius;
//圆心Y坐标
final float cPaintY = h - cRadius;
//控制点结束Y的值
final float endPointY = mTargetGravityHeight;
//更新圆心坐标
mCirclePointX = cPointX;
mCirclePointY = cPaintY;
final Path path = mPath;
path.reset(); //重置
path.moveTo(0, 0);
//坐标系是以最左边的起始点为原点
float lEndPointX, lEndPointY; //结束点的X,Y坐标
float lControlPointX, lControlPointY; //控制点的X,Y坐标
//获取当前切线的弧度
double angle = mTanentAngleInterpolator.getInterpolation(progress) * mTargetAngle;//获取当前的角度
double radian = Math.toRadians(angle); //获取当前弧度
float x = (float) (Math.sin(radian) * cRadius); //求出“股”的长度(长的那条直角边)
float y = (float) (Math.cos(radian) * cRadius); //求出“勾”的长度(短的那条直角边)
lEndPointX = cPointX - x; //以起始点为原点,x坐标就等于圆的X坐标减去股的长度
lEndPointY = cPaintY + y; //以起始点为原点,y坐标就等于圆的y坐标加上勾的长度
lControlPointY = getValueByLine(0, endPointY, progress);//获取控制点的Y坐标
float tHeight = lEndPointY - lControlPointY; //结束点与控制点的Y坐标差值
float tWidth = (float) (tHeight / Math.tan(radian)); //通过计算两个角度是相等的,因此弧度依旧适用
lControlPointX = lEndPointX - tWidth; //结束点的x - ‘勾’ 的长度求出了控制点的X坐标
path.quadTo(lControlPointX, lControlPointY, lEndPointX, lEndPointY); //画左边贝塞尔曲线
path.lineTo(cPointX + (cPointX - lEndPointX), lEndPointY); //左右两个结束点相连
path.quadTo(cPointX + (cPointX - lControlPointX), lControlPointY, w, 0); //画右边贝塞尔曲线
updateContentLayout(cPointX, cPaintY, cRadius);
}
/**
* 测量并设置中心Drawable
*
* @param cx
* @param cy
* @param radius
*/
private void updateContentLayout(float cx, float cy, float radius) {
Drawable drawable = content;
if (drawable != null) {
int margin = mContentDrawableMargin;
int l = (int) (cx - radius + margin);
int r = (int) (cx + radius - margin);
int t = (int) (cy - radius + margin);
int b = (int) (cy + radius - margin);
drawable.setBounds(l, t, r, b);
}
}
其中每一行我都备注了注释,要理解其中的计算,就得先上个图:
我们脑海中要有一个清晰的定义,整个坐标系是基于起始点为原点,向右下延伸X,Y轴,左右的贝塞尔曲线是由一支画笔完成,通过lineTo相连接,因此整个曲线呈V字形。
接下来看代码:
首先求得了progress,这个无需解释,使用加速器只是为了更好的效果。
接下来是w和h,这是控件的某一进度下的宽和高,为什么通过getValueByLine方法就能得到呢?请看输入的参数,起始点是getWidth,也就是整个屏幕的宽度,终点是mTargetWidth也就是我们规定的最小宽度,而getValueByLine正好是求得某一时刻的贝塞尔值,因此将progress输入正好求得从整个屏幕运动到最小宽度之间的某一个宽度,高度同理。
宽高求出来了那个圆心的坐标也就相应出来了,注意整个圆的大小包含margin值,也就是圆与左右两边的边距也是包含的。因为mTargetGravityHeight是我们设定的控制点下移的高度,因此也就是控制点Y的坐标。
因为quadTo方法需要控制点的坐标和结束点的坐标,因此求出这两个点的坐标就大功告成了,接下来就是数学知识了。
首先,通过插值器的方法获得了某一时刻的角度,最后通过Math方法得到了弧度,也就是图中b的角度,半径我们是知道的,所以通过sin可以得出图中x的长度,然后通过半径的x坐标减去x的长度就得到了结束点的x坐标。
通过cos我们可以得到y的长度,圆心Y的坐标加上y的坐标就得到了结束点y的坐标,lEndPointX,lEndPointY的值也就得到了。
因为我们已知控制点上下运行的起始值和最大值,因此我们可以使用getValueByLine获取到当前的y坐标,接下来只需要求出控制点的x坐标就行了,tHeight表示左边这条直角的长直角边,结束点的y坐标已知,控制点的y坐标已知,因此可以求出tHeight。
因为平行线间的角度相等,因此a = c,又因为c+d = e+d = 90; 所以c = e;所以a = e;tHeight已知,e已知,所以tWidth的值就能求出来了。所以控制点的x坐标就等于结束点的x坐标减去tWidth。至此所有的坐标都已经求出来了,所以贝塞尔曲线也就能绘制出来了。
接下来还有个测量的过程,逻辑比较简单:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int MIN_W = (int) (mCircleRadius * 2 + getPaddingLeft() + getPaddingRight()); //需要的最小宽度
int MIN_H = (int) ((mDargHeight * mProgress + 0.5f) //mDargHeight * mProgress = moveSize(即actionMove.getY - actionDown.getY),+0.5f为四舍五入
+ getPaddingBottom() + getPaddingTop());
int widthMeasure = getMeasureSize(widthMeasureSpec, MIN_W);
int heightMeasure = getMeasureSize(heightMeasureSpec, MIN_H);
setMeasuredDimension(widthMeasure, heightMeasure);
}
/**
* 获取所需要的宽/高的测量结果
*
* @param Spec 测量模式
* @param minValue 规定的最小值
* @return 测量结果
*/
private int getMeasureSize(int Spec, int minValue) {
int result;
int mode = MeasureSpec.getMode(Spec);
int size = MeasureSpec.getSize(Spec);
switch (mode) {
case MeasureSpec.AT_MOST: //wrap_content
result = Math.min(size, minValue); //取测量值和规定的最小宽度中的最小值
break;
case MeasureSpec.EXACTLY: //match_parent or exactly num
result = size;
break;
default: //其余情况取最小值
result = minValue;
break;
}
return result;
}
我将视频中的代码做了一下简单的封装,代码中有详细的注释,我这里就不做分析了。
整个分析流程就到这,如果有错误的地方,欢迎指正,Thanks~