前言
之前沒有見到有封裝好的類似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上