光棍節快到了,提前祝愿廣大的單身猿猴,早日脫單,盡快找到另一半。
一直覺得 QQ 的小紅點非常具有創新,新穎。要是自己也能實現類似的效果,那怎一個爽字了得。
先來看看它的最終效果:
效果圖具有哪些效果:
- 在拉伸范圍內的拉伸效果
- 未拉出拉伸范圍釋放后的效果
- 拉出拉伸范圍再拉回的釋放后的效果
- 拉出拉伸范圍釋放后的爆炸效果
涉及的相關知識點:
onLayout 視圖位置
saveLayer 圖層相關知識
Path 的貝賽爾曲線
手勢監聽
ValueAnimator 屬性動畫
一、拉伸效果
我們先來講解第一個知識點,onLayout 方法:
方法預覽:
onLayout(boolean changed, int left, int top, int right, int bottom)
我記得我第一次接觸這個方法的時候對后面兩個參數是理解錯了,還糾結了很久。先來看看一張示意圖就一目了然了:
那么我們可以得出:
right = left + view.getWidth();
bottom = top + view.getHeight();
注意: right 不要理解成視圖控件右邊距離屏幕右邊的距離;bottom 不要理解成視圖控件底部距離屏幕底部的距離。
1、在屏幕中心繪制小圓點
先來啾啾效果圖,非常簡單:
public class QQ_RedPoint extends View {
private Paint mPaint; //畫筆
private int mRadius;
private PointF mCenterPoint;
public QQ_RedPoint(Context context) {
this(context, null);
}
public QQ_RedPoint(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public QQ_RedPoint(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setAntiAlias(true);
mPaint.setStyle(Paint.Style.FILL);
mRadius = 20;
mCenterPoint = new PointF();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mCenterPoint.x = w / 2;
mCenterPoint.y = h / 2;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);
}
}
2、小圓點的拉伸效果
先來看看拉伸的效果圖:
這里就要講解第二個知識點,Path 路徑貝塞爾曲線,如果您對路徑還不了解,請鏈接以下地址:
拉伸的效果圖右三部分組成:
中心小圓
跟手指移動的小圓
兩個圓之間使用貝塞爾曲線填充
我們把拼接過程放大來看看:
咦,這個形狀好熟悉啊,明明我在什么地方見過。怎么越看越覺得像女生用的姨媽巾呢?原來,QQ 這么有深意。
中間圓的效果已經實現了,接著實現跟手指移動的小圓效果:
為了實現手指觸摸屏幕跟隨手指移動的小圓效果,重寫 onTouchEvent 方法(事件不往父控件傳遞):
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
mTouch = true;
}
break;
case MotionEvent.ACTION_UP: {
mTouch = false;
}
}
mCurPoint.set(event.getX(), event.getY());
postInvalidate();
return true;
}
注意:onTouchEvent 方法的返回值為 true,若為 false 捕獲不到 ACTION_DOWN 以后的手指狀態。
自定義View系列教程06--詳解View的Touch事件處理
接著實現貝塞爾曲線填充效果,這也是本篇的難點,后面的實現就輕松。
Ps 技術很菜,希望繪制的草圖能夠幫助到您。
從上效果圖中分析可得:
貝塞爾曲線 P1P2,起點 P1,控制點 C1C2 的中點 Q0,結束點 P2
那么我們所需要的就是求到 P1 , P2 , Q0 點的坐標系,Q0 的坐標很容易得到,那么我們怎么來求 P1 , P2 坐標呢?下面我畫出了怎么求 P1 , P2 坐標的示意圖:
根據示意圖得到:
P1x = x0 + r * sina
P1y = y0 - r * cosa
進一步推得,需要求得 P1 的坐標,需要知道 a 的角度。根據數學公式: tan(a) = dy / dx 。dx,dy 為兩小圓橫縱坐標差值。所以推得 a = arctan(dy / dx) 。同理可以求得 P2 , P3 , P4 坐標。
代碼實現:
P1 , P2 , P3 , P4 的坐標為:
float x = mCurPoint.x;
float y = mCurPoint.y;
float startX = mCenterPoint.x;
float startY = mCenterPoint.y;
float dx = x - startX;
float dy = y - startY;
double a = Math.atan(dy / dx);
float offsetX = (float) (mRadius * Math.sin(a));
float offsetY = (float) (mRadius * Math.cos(a));
// 根據角度計算四邊形的四個點
float p1x = startX + offsetX;
float p1y = startY - offsetY;
float p2x = x + offsetX;
float p2y = y - offsetY;
float p3x = startX - offsetX;
float p3y = startY + offsetY;
float p4x = x - offsetX;
float p4y = y + offsetY;
兩小圓圓心連線中點 Q0 的坐標(本賽爾曲線控制點坐標):
float controlX = (startX + x) / 2;
float controlY = (startY + y) / 2;
效果中 Path 的路徑區域是個封閉的區域:
mPath.reset();
mPath.moveTo(p1x, p1y);
mPath.quadTo(controlX, controlY, p2x, p2y);
mPath.lineTo(p4x, p4y);
mPath.quadTo(controlX, controlY, p3x, p3y);
mPath.lineTo(p1x, p1y);
mPath.close();
路徑繪制完畢,我們來看看 onDraw 方法的繪制:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);
canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);
if (mTouch) {
calculatePath();
canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);
canvas.drawPath(mPath, mPaint);
}
canvas.restore();
super.dispatchDraw(canvas);//繪出該控件的所有子控件
}
相關 saveLayer , restore 的相關知識點請連接以下地址。
自定義控件三部曲之繪圖篇(十三)——Canvas與圖層(一)
自定義控件三部曲之繪圖篇(十四)——Canvas與圖層(二)
我超崇拜的啟航大神的博客。
注意:我們在 onTouchEvent 方法中,我們并沒有對多點觸摸進行處理。如果你感興趣,請繼續關注我的博客。
在 onTouchEvent 方法中調用的是 postInvalidate() 從新繪制,從新繪制有兩個方法:postInvalidate ,invadite 。
invadite 必須在 UI 線程中調用,而 postInvalidate 內部是由Handler的消息機制實現的,可以在任何線程中調用,效率沒有 invadite 高 。
拉伸范圍內釋放效果
在拉伸范圍內手指釋放后的效果:
初始位置只顯示 TextView 控件。替換掉了以前的小圓點。
點擊 TextView 所在區域才能移動 TextView 。
拖動 TextView 且與中心小圓點以貝塞爾曲線連接形成閉合的路徑。
距離的拉伸,小圓的半徑逐漸減少。
拉伸一定的范圍內,釋放手指,按著原來的路徑返回,且運動到中心點有反彈效果。
我們挨著來實現以上效果。
顯示TextView
當前控件繼承 ViewGroup ,我這里繼承的是 FrameLayout 。我們在初始化的時候添加 TextView 控件:
private void init() {
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setAntiAlias(true);
mPaint.setStyle(Paint.Style.FILL);
mRadius = 20;
mCenterPoint = new PointF();
mCurPoint = new PointF();
mPath = new Path();
mDragTextView = new TextView(getContext());
LayoutParams lp = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
mDragTextView.setLayoutParams(lp);
mDragTextView.setPadding(10, 10, 10, 10);
mDragTextView.setBackgroundResource(R.drawable.tv_bg);
mDragTextView.setText("99+");
addView(mDragTextView);
}
在 FrameLayout 中添加了 mDragTextView 控件,并對 mDragTextView 控件做了一些基礎的設置。對應的 tv_bg 資源文件:
tv_bg.xml:
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="10dp"/>
<solid android:color="#ff0000"/>
<stroke android:color="#0f000000" android:width="1dp"/>
</shape>
我們重寫 dispatchDraw 方法(view 重寫 onDraw 方法 ,viewgroup 重寫 dispatchDraw 方法):
@Override
protected void dispatchDraw(Canvas canvas) {
canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);
canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);
canvas.restore();
super.dispatchDraw(canvas);
}
效果圖:
這里我們需要注意 super.dispatchDraw(canvas); 的位置,放在最后與放在最前效果是不一樣的。
@Override
protected void dispatchDraw(Canvas canvas) {
//....繪制操作
super.dispatchDraw(canvas);
//繪制自身然后繪制子元素 可以理解子控件覆蓋在父控件繪制之上
}
與
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
//....繪制操作
//繪制子控件然后繪制自身 可以理解成父控件繪制覆蓋子控件的繪制
}
例,我這里調整一下 super.dispatchDraw(canvas) 的位置:
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
mPaint.setColor(Color.GREEN);//主要是為了區分紅色
canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);
canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);
canvas.restore();
}
效果圖:
點擊TextView拖動效果
點擊 TextView 才能拖動文本,說明要觸摸到 TextView 的矩形區域。可以通過:
int x= (int) event.getX();
int y= (int) event.getY();
if(x>=mDragTextView.getLeft()&&x<=mDragTextView.getRight()&&y<=mDragTextView.getBottom()
&&y>=mDragTextView.getTop()){
mTouch = true;
}
也可以通過:
Rect rect = new Rect();
rect.left = mDragTextView.getLeft();
rect.top = mDragTextView.getTop();
rect.right = mDragTextView.getWidth() + rect.left;
rect.bottom = mDragTextView.getHeight() + rect.top;
if (rect.contains((int) event.getX(), (int) event.getY())) {
mTouch = true;
}
獲取到所點擊區域在 TextView 的矩形之內。
繪制貝塞爾曲線,形成閉合的路徑
我們已經求出了各個點的坐標,連接形成閉合的路徑。 so easy . . .
private void calculatePath() {
float x = mCurPoint.x;
float y = mCurPoint.y;
float startX = mCenterPoint.x;
float startY = mCenterPoint.y;
float dx = x - startX;
float dy = y - startY;
double a = Math.atan(dy / dx);
float offsetX = (float) (mRadius * Math.sin(a));
float offsetY = (float) (mRadius * Math.cos(a));
// 根據角度計算四邊形的四個點
float p1x = startX + offsetX;
float p1y = startY - offsetY;
float p2x = x + offsetX;
float p2y = y - offsetY;
float p3x = startX - offsetX;
float p3y = startY + offsetY;
float p4x = x - offsetX;
float p4y = y + offsetY;
float controlX = (startX + x) / 2;
float controlY = (startY + y) / 2;
mPath.reset();
mPath.moveTo(p1x, p1y);
mPath.quadTo(controlX, controlY, p2x, p2y);
mPath.lineTo(p4x, p4y);
mPath.quadTo(controlX, controlY, p3x, p3y);
mPath.lineTo(p1x, p1y);
mPath.close();
}
啾啾效果圖:
在拉伸的過程當中,小球的大小是沒有變化的。
越拉伸,小球越小
我們可以根據拉伸的距離動態改變小球的半徑,來達到小球變小的效果。
1、計算中心小球與文本的距離(三角函數):
float distance = (float) Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
2、距離越大,小球半徑越小:
int radius = DEFAULT_RADIUS - (int) (distance / 18); //18 根據拉伸情況
if (radius < 8) { //拉伸一定值 固定到最小值
radius = 8;
}
然后把效果繪制到畫布上面:
protected void dispatchDraw(Canvas canvas) {
canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);
if (mTouch) {
calculatePath();
canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);
canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);
canvas.drawPath(mPath, mPaint);//將textview的中心放在當前手指位置
mDragTextView.setX(mCurPoint.x - mDragTextView.getWidth() / 2);
mDragTextView.setY(mCurPoint.y - mDragTextView.getHeight() / 2);
}else {
mDragTextView.setX(mCenterPoint.x - mDragTextView.getWidth() / 2);
mDragTextView.setY(mCenterPoint.y - mDragTextView.getHeight() / 2);
}
canvas.restore();
super.dispatchDraw(canvas);
}
看看效果:
拉伸范圍內,釋放手指后的運動效果
手指釋放,在 onTouchEvent方法 MotionEvent.ACTION_UP 中進行處理。
1、判定當前是否拖動文本:
if (rect.contains((int) event.getRawX(), (int) event.getRawY())) {
mTouch = true;
mTouchText = true;
} else {
mTouchText = false;
}
2、在 MotionEvent.ACTION_UP 中開啟釋放的動畫:
case MotionEvent.ACTION_UP:
mTouch = false;
if (mTouchText) {
startReleaseAnimator();
}
break;
3、釋放動畫效果:
private Animator getReleaseAnimator() {
final ValueAnimator animator = ValueAnimator.ofFloat(1.0f, 0.0f);
animator.setDuration(500);
animator.setRepeatMode(ValueAnimator.RESTART);
animator.addUpdateListener(new MyAnimatorUpdateListener(this) {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mReleaseValue = (float) animation.getAnimatedValue();
postInvalidate();
}
});
animator.setInterpolator(new OvershootInterpolator());
return animator;
}
有關屬性動畫的文章,請鏈接以下地址:
自定義控件三部曲之動畫篇(四)——ValueAnimator基本使用
非常經典的屬性動畫系列講解。
animator.setInterpolator(new OvershootInterpolator()); 設置了插值器,OvershootInterpolator 向前甩一定值后再回到原來位置,就可以實現反彈的效果。
有關插值器的文章,請鏈接以下地址:
自定義控件三部曲之動畫篇(二)——Interpolator插值器
通過 (float) animation.getAnimatedValue() 獲取動畫運到到某一時刻的屬性值,然后刷新界面:
1、根據屬性值來計算文本的位置:
首先獲取文本距離中心小圓的橫縱坐標差值:
float dx = mCurPoint.x - mCenterPoint.x;
float dy = mCurPoint.y - mCenterPoint.y;
文本的位置:
float x = mCurPoint.x - dx * (1.0f - mReleaseValue);
float y = mCurPoint.y - dy * (1.0f - mReleaseValue);
dx * (1.0f - mReleaseValue) , dy * (1.0f - mReleaseValue) 表示在 x 軸,y 軸上的運動距離,根據當前的位置 - 運到的距離 = 文本的位置
獲取到文本的位置坐標,又知道中心點坐標,根據上面的公式繪制出閉合的貝塞爾曲線,就很容易了。
2、釋放動畫過程中,防止多次拖動文本:
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
mMoreDragText = true;
}
@Override
public void onAnimationStart(Animator animation) {
super.onAnimationStart(animation);
mMoreDragText = false;
}
});
拉伸范圍外的效果
拉伸到一定范圍外,然后再拉回來釋放手指,會發現文本回到了中心并回彈效果;拉伸到范圍外釋放手指,會出現爆炸效果。
拉伸到范圍外再拉回釋放效果
拉伸到范圍外釋放爆炸效果
拉伸到范圍外再拉回釋放效果
只要有一次拉伸到范圍外,再拉回來釋放,就不會再繪制中心小圓以及貝塞爾曲線的閉合路徑。所以這里需要一個布爾值的標識,只要小圓半徑減少到一定值就把標識設置為 true
if (mRadius == 8) {
mOnlyOneMoreThan = true;
}
在 dispatchDraw 方法里面繪制文本的位置:
mDragTextView.setX(mCenterPoint.x - mDragTextView.getWidth() / 2);
mDragTextView.setY(mCenterPoint.y - mDragTextView.getHeight() / 2);
拉伸到范圍外釋放爆炸效果
爆炸效果,是用一張張圖片實現的。我們需要添加一個 ImageView 控件來單獨播放爆炸的圖片,具體步驟如下:
1、新增圖片數組:
private int[] mExplodeImages = new int[]{
R.mipmap.idp,
R.mipmap.idq,
R.mipmap.idr,
R.mipmap.ids,
R.mipmap.idt}; //爆炸的圖片集合
2、新增 ImageView 用于播放爆炸效果:
mExplodeImage = new ImageView(getContext());
mExplodeImage.setLayoutParams(lp);
mExplodeImage.setImageResource(R.mipmap.idp);
mExplodeImage.setVisibility(View.INVISIBLE);
addView(mExplodeImage);
mExplodeImage 設置為不占位不可見。
3、范圍外,手指離開,播放爆炸效果:
private Animator getExplodeAnimator() {
ValueAnimator animator = ValueAnimator.ofInt(0, mExplodeImages.length - 1);
animator.setInterpolator(new LinearInterpolator());
animator.setDuration(1000);
animator.addUpdateListener(new MyAnimatorUpdateListener(this) {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mExplodeImage.setBackgroundResource(mExplodeImages[(int) animation.getAnimatedValue()]);
}
});
return animator;
}
mExplodeImage 的位置應該是手指離開的位置:
private void layoutExplodeImage() {
mExplodeImage.setX(mCurPoint.x - mDragTextView.getWidth() / 2);
mExplodeImage.setY(mCurPoint.y - mDragTextView.getHeight() / 2);
}
本篇篇幅比較長,設計的知識點比較多。若你有什么不懂疑問的地方,還請留言。
最后預祝各位過個開心的 11、11