前言
之前没有见到有封装好的类似QQ小红点的控件,虽然公司项目中并没有使用到该效果,不过出于练习与回顾的角度决定自己动手写一个。
贝塞尔曲线
在开始动手写之前,我先介绍一下贝塞尔曲线。贝赛尔曲线(Bézier曲线)是电脑图形学中相当重要的参数曲线。更高维度的广泛化贝塞尔曲线就称作贝塞尔曲面,其中贝塞尔三角是一种特殊的实例。贝塞尔曲线于1962年,由法国工程师皮埃尔·贝塞尔(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。贝塞尔曲线最初由Paul de Casteljau于1959年运用de Casteljau算法开发,以稳定数值的方法求出贝塞尔曲线。
线性贝塞尔曲线
给定点P0、P1,线性贝塞尔曲线只是一条两点之间的直线。这条线由下式给出:
二次方贝塞尔曲线
二次方贝塞尔曲线的路径由给定点P0、P1、P2的函数B(t)追踪:
三次方贝塞尔曲线
P0、P1、P2、P3四个点在平面或在三维空间中定义了三次方贝塞尔曲线。曲线起始于P0走向P1,并从P2的方向来到P3。一般不会经过P1或P2;公式如下:
N次方贝塞尔曲线
贝塞尔曲线代码实现
Android在API=1的时候就提供了贝塞尔曲线的画法,只是隐藏在Path#quadTo()和Path#cubicTo()方法中,一个是二阶贝塞尔曲线,一个是三阶贝塞尔曲线。当然,如果你想自己写个方法,依照上面贝塞尔的表达式也是可以的。不过一般没有必要,因为Android已经在native层为我们封装好了二阶和三阶的函数。
效果分析
首先咱们来看看QQ的效果图,如下:
看到这个效果图,结合上述对贝塞尔的简单描述。你是否已经有想法了?如果有那么请跟着我继续验证你的想法,如果没有请容我向你娓娓道来。
1.首先确定有两个点,这两个点是不一样的。下面是原始红点,上面的手势拖拽之后产生的红点。原始点大小会随着拖拽的距离而逐渐变大,并且当达到阀值会消失。而拖拽点大小始终与原始大小一致保持不变。
2.两点之间的曲线效果。如下图,两条线段,其实就是两条二阶贝塞尔曲线。咱们只要分析出其中一条便可,就拿线1来说吧。贝塞尔曲线1的起点是两个小圆与大圆在某一侧的外切点,控制点是两圆点构成的线段的中心点。
3.如何实现动态变化?这个就好说了,手指移动的时候,小圆不断变化,切点自然也在变,并且两个圆的中心距离位置也是随着改变。
代码实现
梳理完毕之后,咱们就开始撸代码吧。再来回顾前面说的核心内容:1.贝塞尔曲线知识;2.两个圆的变化,以及利用贝塞尔曲线绘制两个圆拉动的效果,并且随着距离变化而变化。OK,下面开始看代码
首先创建类DragPointView并且继承View
public class DragPointView extends View {...}
成员变量
这里为了简便,一些可配属性直接写死。后续完善的时候会将属性抽离出来,供使用者可以通过自定义属性控制该控件的样式。
public static final int DEFAULT_WIDTH = 23; // 默认宽度 单位:dp
public static final int DEFAULT_HEIGHT = 23; // 默认高度 单位:dp
private Paint mPaint; // 画笔 绘制的是圆和贝塞尔曲线路径 外貌一样
private Path mPath; // 存储贝塞尔曲线
private int width,height; // 控件宽高
private boolean isInCircle; // 判断DOWN事件是否有效
private float downX,downY; // 按下的位置
private PointF[] mDragTangentPoint; // 两个圆切点中位于拖拽圆上的两个点
private PointF[] mCenterTangentPoint; // 两个圆切点中位于初始圆上的两个点
private PointF mCenterCircle; // 初始圆圆心
private PointF mCenterCircleCopy; // 初始圆圆心copy
private PointF mDragCircle; // 拖拽圆圆心
private PointF mDragCircleCopy; // 拖拽圆圆心copy
private double mDistanceCircles; // 两个圆心距离
private PointF mControlPoint; // 贝塞尔曲线控制点 两条曲线控制点一致
private boolean mIsOut; // 拖拽是否超出范围
private ValueAnimator mRecoveryAnim; // 没超过范围时圆的恢复动画
// 初始半径,运动时初始圆半径,拖拽圆半径
private float mRadius, mRatioRadius, mDragRadius;
private int mDragLength = 500; // 最长允许拖拽长度
private float mDragMinRatio = 0.5f; // 初始圆允许最小比
private long mRecoveryDuration = 200l; // 恢复动画时长
private long mFrameDuration = 200l; // 气泡帧动画时长
/**
* 初始化操作
*/
private void init() {
mPath = new Path();
mPaint = new Paint();
mPaint.setStyle(Paint.Style.FILL);
mPaint.setTextSize(18f);
mPaint.setColor(0xffff0000);
mPaint.setAntiAlias(true);
mPaint.setDither(true);
mDragRadius = mRadius = DensityUtil.dip2px(getContext(),23)/2;
mDragTangentPoint = new PointF[2];
mCenterTangentPoint = new PointF[2];
mControlPoint = new PointF();
mCenterCircle = new PointF();
mCenterCircleCopy = new PointF();
mDragCircle = new PointF();
mDragCircleCopy = new PointF();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
width = w;
height = h;
mCenterCircle.x = width/2;
mCenterCircle.y = height/2;
}
测量过程
自定义控件时候要注意处理宽高为MeasureSpec.AT_MOST的情况,一般做法:设置默认宽高或者根据内容需要设置宽高。此外,由于咱们的自定义控件是直接继承View,如果需要的话还得为其支持Padding属性(这里简单说一下怎么支持,主要两处:1.测量的时候要考虑padding 2.绘制的时候考虑padding),小红点这个控件我觉得就没必要了哈。略过~
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
if(heightMode == MeasureSpec.AT_MOST){
heightSize = DensityUtil.dip2px(getContext(),DEFAULT_HEIGHT);
}
if(widthMode == MeasureSpec.AT_MOST){
widthSize = DensityUtil.dip2px(getContext(),DEFAULT_WIDTH);
}
setMeasuredDimension(widthSize,heightSize);
}
绘制过程
代码很简练,主要是就三个方法。大概说一下逻辑。drawCenterCircle判断若没有拖拽超出范围则绘制初始圆,并且圆的半径是要根据mDistanceCircles、mDragLength以及mDragMinRatio实时计算的。drawDragCircle就没有太多逻辑了。drawBezierLine中将获取到的四个切点配合贝塞尔曲线连接为封闭空间后绘制即可。这里先理清逻辑就行,后续到具体方法具体分析~
@Override
protected void onDraw(Canvas canvas) {
drawCenterCircle(canvas);
if(isInCircle){
drawDragCircle(canvas);
drawBezierLine(canvas);
}
}
绘制初始圆
首先,如果此时手势动作已超出定义的范围。那么直接return~
否则,计算出应该绘制的圆的半径,简单的比例乘除~
private void drawCenterCircle(Canvas canvas) {
if(mIsOut) return;
mRatioRadius = mRadius;
if(isInCircle && mDragMinRatio < 1.f){
mRatioRadius = (float) (Math.max((mDragLength - mDistanceCircles)*1.f/ mDragLength, mDragMinRatio) * mRadius);
}
canvas.drawCircle(mCenterCircle.x,mCenterCircle.y, mRatioRadius,mPaint);
}
绘制拖拽圆
private void drawDragCircle(Canvas canvas) {
canvas.drawCircle(mDragCircle.x, mDragCircle.y, mRadius,mPaint);
}
绘制贝塞尔曲线部分
首先,找到两个圆形成的4个切点p1,p2,p3,p4,此处使用的方式通过指定圆心与一条已知斜率的直线去获取切点数学不好的我也没办法啦,因为我也懵懵懂懂数学什么的忘得差不多了,但是请记住,这不是重点,哈哈。
其次,找到控制点,有人说了:“这个我会”~
最后,将四个点用Path连接并绘制,切记最后要使路径为闭合空间。p3-p4直连,p4-p1贝塞尔曲线,p1-p2直连,p2-p3贝塞尔曲线~ OK
private void drawBezierLine(Canvas canvas) {
if(mIsOut) return;
float dx = mDragCircle.x - mCenterCircle.x;
float dy = mDragCircle.y - mCenterCircle.y;
// 控制点
mControlPoint.set((mDragCircle.x + mCenterCircle.x) / 2,
(mDragCircle.y + mCenterCircle.y) / 2);
// 四个切点
if (dx != 0) {
float k1 = dy / dx;
float k2 = -1 / k1;
mDragTangentPoint = MathUtil.getIntersectionPoints(
mDragCircle.x, mDragCircle.y, mDragRadius, (double) k2);
mCenterTangentPoint = MathUtil.getIntersectionPoints(
mCenterCircle.x, mCenterCircle.y, mRatioRadius, (double) k2);
} else {
mDragTangentPoint = MathUtil.getIntersectionPoints(
mDragCircle.x, mDragCircle.y, mDragRadius, (double) 0);
mCenterTangentPoint = MathUtil.getIntersectionPoints(
mCenterCircle.x, mCenterCircle.y, mRatioRadius, (double) 0);
}
// 路径构建
mPath.reset();
mPath.moveTo(mCenterTangentPoint[0].x, mCenterTangentPoint[0].y);
mPath.quadTo(mControlPoint.x, mControlPoint.y,mDragTangentPoint[0].x,mDragTangentPoint[0].y);
mPath.lineTo(mDragTangentPoint[1].x, mDragTangentPoint[1].y);
mPath.quadTo(mControlPoint.x, mControlPoint.y,
mCenterTangentPoint[1].x, mCenterTangentPoint[1].y);
mPath.close();
canvas.drawPath(mPath,mPaint);
}
/**
* Get the point of intersection between circle and line.
* 获取 通过指定圆心,斜率为lineK的直线与圆的交点。
*
* @param radius The circle radius.
* @param lineK The slope of line which cross the pMiddle.
* @return
*/
public static PointF[] getIntersectionPoints(float cx,float cy, float radius, Double lineK) {
PointF[] points = new PointF[2];
float radian, xOffset = 0, yOffset = 0;
if(lineK != null){
radian= (float) Math.atan(lineK);
xOffset = (float) (Math.cos(radian) * radius);
yOffset = (float) (Math.sin(radian) * radius);
}else {
xOffset = radius;
yOffset = 0;
}
points[0] = new PointF(cx + xOffset, cy + yOffset);
points[1] = new PointF(cx - xOffset, cy - yOffset);
return points;
}
这时候大家有疑问啦,LZ是不是把触摸事件给漏了~,漏不了,这就来
触摸事件
DOWN,MOVE,CANCEL,UP事件,需要做什么?重绘是肯定的~
DOWN事件:判断点击位置是否处于初始圆范围内,此处直接判断的是否位于圆的外切矩形内,当然如果你非要往细的扣,可以使用Region
MOVE事件:记录拖拽圆的圆心位置,即事件位置。计算两个圆心的距离,并且判断是否超过阀值啦。
CANCEL与UP事件:无非就是越界与否的判断,加上不同结果对应的动画效果
这里想到个事,如果的长动画或者其他类似的,记得在onDetachedFromWindow方法中处理一下,否则造成内存泄漏
@Override
public boolean onTouchEvent(MotionEvent event) {
if(mRecoveryAnim ==null || !mRecoveryAnim.isRunning()) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
downY = event.getY();
isInCircle = isInPointCircle(downX, downY);
postInvalidate();
break;
case MotionEvent.ACTION_MOVE:
mDragCircle.x = event.getX();
mDragCircle.y = event.getY();
mDistanceCircles = MathUtil.getDistance(mCenterCircle, mDragCircle);
mIsOut = mIsOut ? mIsOut : mDistanceCircles > mDragLength;
postInvalidate();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
upAndCancelEvent();
break;
}
}
return true;
}
/**
* Gets the distance between two points.
* 获取两点之间的距离
*
* @param p1
* @param p2
* @return
*/
public static double getDistance(PointF p1,PointF p2){
return Math.sqrt((p1.x-p2.x)*(p1.x-p2.x)+(p1.y-p2.y)*(p1.y-p2.y));
}
private void upAndCancelEvent() {
if(isInCircle && mDistanceCircles == 0) {
reset();
}else if(!mIsOut){
mCenterCircleCopy.set(mCenterCircle.x, mCenterCircle.y);
mDragCircleCopy.set(mDragCircle.x, mDragCircle.y);
if(mRecoveryAnim == null){
mRecoveryAnim = ValueAnimator.ofFloat(0.f,1.5f);
mRecoveryAnim.setDuration(mRecoveryDuration);
mRecoveryAnim.setInterpolator(new AccelerateInterpolator());
mRecoveryAnim.addUpdateListener(this);
mRecoveryAnim.addListener(this);
}
mRecoveryAnim.start();
}else{
reset();
}
}
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
float value = (float) valueAnimator.getAnimatedValue();
mDragCircle.x = mDragCircleCopy.x + (mCenterCircleCopy.x - mDragCircleCopy.x)*value;
mDragCircle.y = mDragCircleCopy.y + (mCenterCircleCopy.y - mDragCircleCopy.y)*value;
postInvalidate();
}
@Override
public void onAnimationEnd(Animator animator) {
reset();
}
private void reset() {
mIsOut = false;
isInCircle = false;
mDragCircle.x = mCenterCircle.x;
mDragCircle.y = mCenterCircle.y;
mDistanceCircles = 0;
postInvalidate();
}
效果图
总结
1.贝塞尔曲线简单介绍
2.QQ小红点效果分析
3.将分析所得代码实现
tip:目前只是小demo,诸多地方不完善。后续会考虑封装成开源库放到github上