qq的未讀消息,算是一個比較好玩的效果,趁着最近時間比較多,參考了網上的一些資料之後,本次實作一個仿照qq未讀消息的拖拽小紅點:
首先我們從最基本的原理開始分析,看一張圖:
這個圖該怎麼繪制呢?實際上我們這裡是先繪制兩個圓,然後将兩個圓的切點通過貝塞爾曲線連接配接起來就達到這個效果了。至于貝塞爾曲線的概念,這裡就不多做解釋了,百度一下就知道了。
切點怎麼算呢,這裡我們稍微複習一些國中的數學知識。看了這個圖之後,求出四個切點應該是輕而易舉了。
現在思路已經很清晰了,按照我們的思路,開撸。
首先是我們計算切點以及各坐标點的工具類
public class geometryutils {
/**
* as meaning of method name.
* 獲得兩點之間的距離
* @param p0
* @param p1
* @return
*/
public static float getdistancebetween2points(pointf p0, pointf p1) {
float distance = (float) math.sqrt(math.pow(p0.y - p1.y, 2) + math.pow(p0.x - p1.x, 2));
return distance;
}
* get middle point between p1 and p2.
* 獲得兩點連線的中點
* @param p2
public static pointf getmiddlepoint(pointf p1, pointf p2) {
return new pointf((p1.x + p2.x) / 2.0f, (p1.y + p2.y) / 2.0f);
* get point between p1 and p2 by percent.
* 根據百分比擷取兩點之間的某個點坐标
* @param percent
public static pointf getpointbypercent(pointf p1, pointf p2, float percent) {
return new pointf(evaluatevalue(percent, p1.x , p2.x), evaluatevalue(percent, p1.y , p2.y));
* 根據分度值,計算從start到end中,fraction位置的值。fraction範圍為0 -> 1
* @param fraction
* @param start
* @param end
public static float evaluatevalue(float fraction, number start, number end){
return start.floatvalue() + (end.floatvalue() - start.floatvalue()) * fraction;
* get the point of intersection between circle and line.
* 擷取 通過指定圓心,斜率為linek的直線與圓的交點。
*
* @param pmiddle the circle center point.
* @param radius the circle radius.
* @param linek the slope of line which cross the pmiddle.
public static pointf[] getintersectionpoints(pointf pmiddle, 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.sin(radian) * radius);
yoffset = (float) (math.cos(radian) * radius);
}else {
xoffset = radius;
yoffset = 0;
}
points[0] = new pointf(pmiddle.x + xoffset, pmiddle.y - yoffset);
points[1] = new pointf(pmiddle.x - xoffset, pmiddle.y + yoffset);
return points;
}
然後下面看下我們的核心繪制代碼,代碼注釋比較全,此處就不多做解釋了。
/**
* 繪制貝塞爾曲線部分以及固定圓
* @param canvas
private void drawgoopath(canvas canvas) {
path path = new path();
//1. 根據目前兩圓圓心的距離計算出固定圓的半徑
float distance = (float) geometryutils.getdistancebetween2points(mdragcenter, mstickcenter);
stickcircletempradius = getcurrentradius(distance);
//2. 計算出經過兩圓圓心連線的垂線的draglinek(對邊比臨邊)。求出四個交點坐标
float xdiff = mstickcenter.x - mdragcenter.x;
double draglinek = null;
if (xdiff != 0) {
draglinek = (double) ((mstickcenter.y - mdragcenter.y) / xdiff);
//分别獲得經過兩圓圓心連線的垂線與圓的交點(兩條垂線平行,是以draglinek相等)。
pointf[] dragpoints = geometryutils.getintersectionpoints(mdragcenter, dragcircleradius, draglinek);
pointf[] stickpoints = geometryutils.getintersectionpoints(mstickcenter, stickcircletempradius, draglinek);
//3. 以兩圓連線的0.618處作為 貝塞爾曲線 的控制點。(選一個中間點附近的控制點)
pointf pointbypercent = geometryutils.getpointbypercent(mdragcenter, mstickcenter, 0.618f);
// 繪制兩圓連接配接閉合
path.moveto((float) stickpoints[0].x, (float) stickpoints[0].y);
path.quadto((float) pointbypercent.x, (float) pointbypercent.y,
(float) dragpoints[0].x, (float) dragpoints[0].y);
path.lineto((float) dragpoints[1].x, (float) dragpoints[1].y);
(float) stickpoints[1].x, (float) stickpoints[1].y);
canvas.drawpath(path, mpaintred);
// 畫固定圓
canvas.drawcircle(mstickcenter.x, mstickcenter.y, stickcircletempradius, mpaintred);
}
此時我們已經實作了繪制的核心代碼,然後我們加上touch事件的監聽,達到動态的更新dragpoint的中心點位置以及stickpoint半徑的效果。當手擡起的時候,添加一個屬性動畫,達到回彈的效果。
@override
public boolean ontouchevent(motionevent event) {
switch (motioneventcompat.getactionmasked(event)) {
case motionevent.action_down: {
isoutofrange = false;
updatedragpointcenter(event.getrawx(), event.getrawy());
break;
}
case motionevent.action_move: {
//如果兩圓間距大于最大距離mmaxdistance,執行拖拽結束動畫
pointf p0 = new pointf(mdragcenter.x, mdragcenter.y);
pointf p1 = new pointf(mstickcenter.x, mstickcenter.y);
if (geometryutils.getdistancebetween2points(p0, p1) > mmaxdistance) {
isoutofrange = true;
updatedragpointcenter(event.getrawx(), event.getrawy());
return false;
}
case motionevent.action_up: {
handleactionup();
default: {
return true;
* 手勢擡起動作
private void handleactionup() {
if (isoutofrange) {
// 當拖動dragpoint範圍已經超出mmaxdistance,然後又将dragpoint拖回mresetdistance範圍内時
if (geometryutils.getdistancebetween2points(mdragcenter, mstickcenter) < mresetdistance) {
//reset
return;
// dispappear
} else {
//手指擡起時,彈回動畫
manim = valueanimator.offloat(1.0f);
manim.setinterpolator(new overshootinterpolator(5.0f));
final pointf startpoint = new pointf(mdragcenter.x, mdragcenter.y);
final pointf endpoint = new pointf(mstickcenter.x, mstickcenter.y);
manim.addupdatelistener(new animatorupdatelistener() {
@override
public void onanimationupdate(valueanimator animation) {
float fraction = animation.getanimatedfraction();
pointf pointbypercent = geometryutils.getpointbypercent(startpoint, endpoint, fraction);
updatedragpointcenter((float) pointbypercent.x, (float) pointbypercent.y);
});
manim.addlistener(new animatorlisteneradapter() {
public void onanimationend(animator animation) {
//reset
if (geometryutils.getdistancebetween2points(startpoint, endpoint) < 10) {
manim.setduration(100);
} else {
manim.setduration(300);
manim.start();
此時我們拖拽的核心代碼基本都已經完成,實際效果如下:
現在小紅點的繪制基本告一段落,我們不得不去思考真正的難點。那就是如何将我們前面的這個gooview應用到實際呢?看實際效果我們的小紅點是放在listview裡面的,如果是這樣的話,就代表我們的gooview的拖拽範圍是肯定無法超過父控件item的區域的。
那麼我們要如何實作小紅點可以随便的在整個螢幕拖拽呢?我們這裡稍微整理一下思路。
1.先在listview的item布局中先放入一個小紅點。
2.當我們touch到這個小紅點的時候,隐藏這個小紅點,然後根據我們布局中小紅點的位置初始化一個gooview并且添加到windowmanager中嗎,達到gooview可以全屏拖動的效果。
3.在添加gooview到windowmanager中的時候,記錄初始小紅點stickpoint的位置,然後根據stickpoint和dragpointde位置是否超出我們的消失界限來判斷接下來的邏輯。
4.根據gooview的最終狀态,顯示回彈或者消失動畫。
思路有了,那麼就上代碼,根據第一步,我們完成listview的item布局。
<?xml version="1.0" encoding="utf-8"?>
<relativelayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="80dp"
android:minheight="80dp">
<imageview
android:id="@+id/iv_head"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_centervertical="true"
android:layout_marginleft="20dp"
android:src="@mipmap/head"/>
<textview
android:id="@+id/tv_content"
android:layout_width="wrap_content"
android:gravity="center"
android:layout_torightof="@+id/iv_head"
android:text="content - "
android:textsize="25sp"/>
<linearlayout
android:id="@+id/ll_point"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_alignparentend="true"
android:layout_alignparentright="true"
android:layout_alignparenttop="true"
android:gravity="center">
<textview
android:id="@+id/point"
android:layout_width="wrap_content"
android:layout_height="18dp"
android:background="@drawable/red_bg"
android:gravity="center"
android:singleline="true"
android:textcolor="@android:color/white"
android:textsize="12sp"/>
</linearlayout>
</relativelayout>
效果如下,要注意的是,對比qq的真實體驗,小紅點周邊範圍點選的時候,都是可以直接拖拽小紅點的。考慮到紅點的點選範圍比較小,是以給紅點增加了一個寬高80dp的父layout,然後我們将touch小紅點事件更改為touch小紅點父layout,這樣隻要我們點選了小紅點的父layout範圍,都會添加gooview到windowmanager中。
接下來第二步,我們完成添加gooview到windowmanager中的代碼。
由于我們的gooview初始添加是從listviewitem中紅點的touch事件開始的,是以我們先完成listview adapter的實作。
public class gooviewaapter extends baseadapter {
private context mcontext;
//記錄已經remove的position
private hashset<integer> mremoved = new hashset<integer>();
private list<string> list = new arraylist<string>();
public gooviewaapter(context mcontext, list<string> list) {
super();
this.mcontext = mcontext;
this.list = list;
@override
public int getcount() {
return list.size();
public object getitem(int position) {
return list.get(position);
public long getitemid(int position) {
return position;
public view getview(final int position, view convertview, viewgroup parent) {
if (convertview == null) {
convertview = view.inflate(mcontext, r.layout.list_item_goo, null);
viewholder holder = viewholder.getholder(convertview);
holder.mcontent.settext(list.get(position));
//item固定小紅點layout
linearlayout pointlayout = holder.mpointlayout;
//item固定小紅點
final textview point = holder.mpoint;
boolean visiable = !mremoved.contains(position);
pointlayout.setvisibility(visiable ? view.visible : view.gone);
if (visiable) {
point.settext(string.valueof(position));
pointlayout.settag(position);
gooviewlistener mgoolistener = new gooviewlistener(mcontext, pointlayout) {
public void ondisappear(pointf mdragcenter) {
super.ondisappear(mdragcenter);
mremoved.add(position);
notifydatasetchanged();
utils.showtoast(mcontext, "position " + position + " disappear.");
public void onreset(boolean isoutofrange) {
super.onreset(isoutofrange);
notifydatasetchanged();//重新整理listview
utils.showtoast(mcontext, "position " + position + " reset.");
};
//在point父布局内的觸碰事件都進行監聽
pointlayout.setontouchlistener(mgoolistener);
return convertview;
static class viewholder {
public imageview mimage;
public textview mpoint;
public linearlayout mpointlayout;
public textview mcontent;
public viewholder(view convertview) {
mimage = (imageview) convertview.findviewbyid(r.id.iv_head);
mpoint = (textview) convertview.findviewbyid(r.id.point);
mpointlayout = (linearlayout) convertview.findviewbyid(r.id.ll_point);
mcontent = (textview) convertview.findviewbyid(r.id.tv_content);
public static viewholder getholder(view convertview) {
viewholder holder = (viewholder) convertview.gettag();
if (holder == null) {
holder = new viewholder(convertview);
convertview.settag(holder);
return holder;
由于listview需要知道gooview的狀态,是以我們在gooview中增加一個接口,用于listview回調處理後續的邏輯。
interface ondisappearlistener {
/**
* gooview disapper
*
* @param mdragcenter
*/
void ondisappear(pointf mdragcenter);
* gooview onreset
* @param isoutofrange
void onreset(boolean isoutofrange);
}
建立一個實作了ontouchlistener以及ondisappearlistener 方法的的類,最後将這個實作類設定給item中的紅點layout。
public class gooviewlistener implements ontouchlistener, ondisappearlistener {
private windowmanager mwm;
private windowmanager.layoutparams mparams;
private gooview mgooview;
private view pointlayout;
private int number;
private final context mcontext;
private handler mhandler;
public gooviewlistener(context mcontext, view pointlayout) {
this.pointlayout = pointlayout;
this.number = (integer) pointlayout.gettag();
mgooview = new gooview(mcontext);
mwm = (windowmanager) mcontext.getsystemservice(context.window_service);
mparams = new windowmanager.layoutparams();
mparams.format = pixelformat.translucent;//使視窗支援透明度
mhandler = new handler(mcontext.getmainlooper());
public boolean ontouch(view v, motionevent event) {
int action = motioneventcompat.getactionmasked(event);
// 當按下時,将自定義view添加到windowmanager中
if (action == motionevent.action_down) {
viewparent parent = v.getparent();
// 請求其父級view不攔截touch事件
parent.requestdisallowintercepttouchevent(true);
int[] points = new int[2];
//擷取pointlayout在螢幕中的位置(layout的左上角坐标)
pointlayout.getlocationinwindow(points);
//擷取初始小紅點中心坐标
int x = points[0] + pointlayout.getwidth() / 2;
int y = points[1] + pointlayout.getheight() / 2;
// 初始化目前點選的item的資訊,數字及坐标
mgooview.setstatusbarheight(utils.getstatusbarheight(v));
mgooview.setnumber(number);
mgooview.initcenter(x, y);
//設定目前gooview消失監聽
mgooview.setondisappearlistener(this);
// 添加目前gooview到windowmanager
mwm.addview(mgooview, mparams);
pointlayout.setvisibility(view.invisible);
// 将所有touch事件轉交給gooview處理
mgooview.ontouchevent(event);
public void ondisappear(pointf mdragcenter) {
//disappear 下一步完成
public void onreset(boolean isoutofrange) {
// 當dragpoint彈回時,去除該view,等下次action_down的時候再添加
if (mwm != null && mgooview.getparent() != null) {
mwm.removeview(mgooview);
這樣下來,我們基本上完成了大部分功能,現在還差最後一步,就是gooview超出範圍消失後的處理,這裡我們用一個幀動畫來完成爆炸效果。
public class bubblelayout extends framelayout {
context context;
public bubblelayout(context context) {
super(context);
this.context = context;
private int mcenterx, mcentery;
public void setcenter(int x, int y) {
mcenterx = x;
mcentery = y;
requestlayout();
protected void onlayout(boolean changed, int left, int top, int right,
int bottom) {
view child = getchildat(0);
// 設定view到指定位置
if (child != null && child.getvisibility() != gone) {
final int width = child.getmeasuredwidth();
final int height = child.getmeasuredheight();
child.layout((int) (mcenterx - width / 2.0f), (int) (mcentery - height / 2.0f)
, (int) (mcenterx + width / 2.0f), (int) (mcentery + height / 2.0f));
}
//播放氣泡爆炸動畫
imageview imageview = new imageview(mcontext);
imageview.setimageresource(r.drawable.anim_bubble_pop);
animationdrawable manimdrawable = (animationdrawable) imageview
.getdrawable();
final bubblelayout bubblelayout = new bubblelayout(mcontext);
bubblelayout.setcenter((int) mdragcenter.x, (int) mdragcenter.y - utils.getstatusbarheight(mgooview));
bubblelayout.addview(imageview, new framelayout.layoutparams(
android.widget.framelayout.layoutparams.wrap_content,
android.widget.framelayout.layoutparams.wrap_content));
mwm.addview(bubblelayout, mparams);
manimdrawable.start();
// 播放結束後,删除該bubblelayout
mhandler.postdelayed(new runnable() {
public void run() {
mwm.removeview(bubblelayout);
}, 501);
本文作者:佚名
來源:51cto