最近公司的項目里,需要通過懸浮窗進行控制,懸浮窗根據(jù)手勢進行拖動食零。當時同事給的建議用 ViewDragHelper 來實現(xiàn)(原諒沒玩過這個東西,網(wǎng)上看了下教程挺牛逼的寂屏,算了贰谣,還是選擇用手勢監(jiān)聽做吧),首先先給大伙看下最終的項目實現(xiàn)效果(模擬器上可能會卡頓迁霎,實際的運行效果還是很流暢的)吱抚。當然,最后我也不會把公司項目代碼分享給大家伙考廉,這里就給大家講解下實現(xiàn)的思路秘豹。
看完效果圖,希望你能有點感興趣昌粤,然后就開始上代碼啦~既绕,首先通過 WindowManager 添加一個指示的 indicatorView(就是側(cè)邊紅色的條),用來提示用戶通過這邊進行拖動懸浮窗涮坐,接著在手指在 indicatorView 按下的時候凄贩,添加一個空的 RelativeLayout,作為懸浮窗的 rootview袱讹,然后往 rootview 添加懸浮窗內(nèi)容 contentView疲扎,通過 layout 方法,改變 contentView 的布局參數(shù)捷雕,也可以通過 LayoutParam 來設(shè)置评肆,實現(xiàn)最終的效果》乔可能文字表達不夠明確瓜挽,貼一張手繪原理圖
接下來就是代碼一波流了,首先定義一個手勢監(jiān)聽回調(diào)類征绸,主要用來判斷 indicatorView 的滑動的距離以及方向久橙,然后懸浮窗可以根據(jù) indicatorView 的滑動方向進行拖動
public abstract class OnFlingListener {
// 手指按下
public void onFingerDown() {
}
// 手指抬起
public void onFingerUp(int slideDirection) {
}
// 手勢上滑
public void onScrollUp(float scrollY) {
}
// 手勢下滑
public void onScrollDown(float scrollY) {
}
// 手勢左滑
public void onScrollLeft(float scrollX) {
}
// 手勢右滑
public void onScrollRight(float scrollX) {
}
}
定義完手勢回調(diào),就需要定義用來監(jiān)聽拖動手勢的 indicatorView 啦管怠,其主要作用是當焦點落到 indicatorView 的時候淆衷,通過用戶的手勢來拖動懸浮窗活動,這個可以根據(jù)自己的喜好進行編寫
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/floating_bar_outside">
<TextView
android:id="@+id/touch_view"
android:layout_width="200dp"
android:layout_height="5dp"
android:background="@color/colorAccent"
android:clickable="true"
android:focusable="true" />
</LinearLayout>
方便起見渤弛,我這邊用 TextView 來作為 indicatorView祝拯,做好準備工作就要開始編寫實際的操作邏輯啦,首先定義一個內(nèi)部手勢類,用來實現(xiàn)操作邏輯
class FloatViewOnGestureListener extends GestureDetector.SimpleOnGestureListener {
// 回調(diào)類
private OnFlingListener mFlingListener;
// ..... 省略部分無關(guān)代碼
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
float x = e2.getX() - e1.getX();
float y = e2.getY() - e1.getY();
float x_abs = Math.abs(x);
float y_abs = Math.abs(y);
scrollX = e2.getX();
scrollY = e2.getY();
// x > y 且 x 滑動距離大于閥值佳头,則是水平滑動鹰贵,否則是垂直滑動
if (x_abs >= y_abs && x_abs > X_SLOP) {
// 如果 x 的滑動距離大于 0 則是右滑,否則為左滑
if (x > 0 && mFlingListener != null) {
mOnFlingListener.onScrollRight(x_abs);
} else if (x < 0 && mFlingListener != null) {
mOnFlingListener.onScrollLeft(x_abs);
}
// 用來記錄抬手前的最后一下是左滑還是右滑康嘉,最后通過回調(diào)函數(shù)傳回
mDirection = (scrollX - lastScrollX) >= 0 ? DIRECTION_RIGHT : DIRECTION_LEFT;
} else if (y_abs >= x_abs && y_abs > Y_SLOP) {
// 如果 y 的滑動距離大于 0 則是下滑碉输, 否則上滑
if (y > 0 && mFlingListener != null) {
mOnFlingListener.onScrollDown(y_abs);
} else if (y < 0 && mFlingListener != null) {
mOnFlingListener.onScrollUp(y_abs);
}
// 用來記錄抬手前最后一下是上滑還是下滑
mDirection = (scrollY - lastScrollY >= 0) ? DIRECTION_DOWN : DIRECTION_UP;
}
lastScrollX = scrollX;
lastScrollY = scrollY;
return super.onScroll(e1, e2, distanceX, distanceY);
}
}
這里通過兩次手指的位置,來判斷當前手指的滑動方向亭珍。通過比較 x 軸的移動距離和 y 的移動距離敷钾,判斷是上下滑動還是左右滑動,然后通過滑動的距離是否大于 0 判斷滑動的方向肄梨,因為當你的 indicatorView 在右側(cè)的時候阻荒,如果初始滑動距離大于 0 的話,根本就是不可能的众羡。最后還需要判斷最后一下手指的滑動方向侨赡,如果和初始的方向相反邑时,則需要將拖出來的懸浮窗自動回滾到初始狀態(tài)驼仪。
接著就需要實現(xiàn)對的 indicatorView 做手勢監(jiān)聽
mTouchView.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 按下時候震動提醒
mVibrator.vibrate(50);
mGestureDetector.onTouchEvent(event);
if (mOnFlingListener != null)
mOnFlingListener.onFingerDown();
break;
case MotionEvent.ACTION_MOVE:
mGestureDetector.onTouchEvent(event);
break;
case MotionEvent.ACTION_UP:
if (mOnFlingListener != null)
// 將最后的滑動方向通過回調(diào)傳出
mOnFlingListener.onFingerUp(mDirection);
// 抬手的時候注意把 direction 回歸初始狀態(tài)
mDirection = NO_ACTION;
}
return true;
}
});
當手指按下的時候梳星,做下震動,用于提示作用甜害,然后根據(jù)不同的手勢操作,做相應(yīng)的回調(diào)球昨,當抬手指的時候尔店,記得需要將手勢方向設(shè)置回初始值,OK主慰,indicatorView 的內(nèi)容大概就那么多嚣州,具體的操作,需要通過懸浮窗 FloatWindow 去實現(xiàn)共螺。在實現(xiàn)邏輯之前该肴,因為整體都在懸浮窗上實現(xiàn),需要定義懸浮窗內(nèi)容的一些必要屬性藐不,因為 indicatorView 和 rootView 的屬性差不多匀哄,所以只列出 indicatorView 的屬性列表,具體的可以看 demo
mParams = new WindowManager.LayoutParams();
mParams.packageName = FloatingApplication.getContext().getPackageName();
mParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
mParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
// 當懸浮窗顯示的時候可以獲取到焦點
mParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
| WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN;
// 需要適配 8.0雏蛮,當 8.0 以上的版本
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
mParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
} else {
mParams.type = WindowManager.LayoutParams.TYPE_PHONE;
}
mParams.format = PixelFormat.RGBA_8888;
flag 作用主要是讓懸浮窗能夠獲取到焦點涎嚼,能夠消費點擊等事件,還需要注意的是挑秉,在 8.0 之后的版本法梯,懸浮窗 type 只能使用 TYPE_APPLICATION_OVERLAY
之前的 type 都過時無效了。定義完屬性就需要定義 indicatorView 犀概, rootview 以及懸浮窗具體的內(nèi)容 contentView
public void initFloatView() {
mFloatView = new FloatView(BaseApplication.getContext(), new OnFlingListener() {
@Override
public void onFingerDown() {
// 添加 rootview立哑,如果已經(jīng)存在了夜惭,直接根據(jù) params 進行更新布局就行,如果不存在就添加
try {
mWindowManager.updateViewLayout(mContainer, mContainerParams);
} catch (Exception e) {
e.printStackTrace();
mWindowManager.addView(mContainer, mContainerParams);
// 判斷懸浮窗是否已經(jīng)顯示的標志位
isCenterShow = true;
}
// 根據(jù)布局獲取懸浮窗 contentView
mContentView = LayoutInflater.from(mContext).inflate(R.layout.content_view, null);
// 懸浮窗 contentView 布局屬性
RelativeLayout.LayoutParams contentLp = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
mContentView.setLayoutParams(contentLp);
// 剛開始添加的時候設(shè)置不可見刁憋,因為設(shè)置了 LayoutParam 是 MATCH_PARENT滥嘴,等拖動操作再顯示即可
mContentView.setVisibility(View.INVISIBLE);
mContainer.addView(mContentView);
// 根據(jù)滑動的方向,設(shè)置最開始的布局位置
switch (mSlideType) {
// 從右往左滑動至耻,懸浮窗內(nèi)容全部位于屏幕的右側(cè)若皱,所以此時的 left, right 屬性都是屏幕的寬度
case SLIDE_RIGHT_TO_LEFT:
mContentView.layout(mScreenWidth, 0, mScreenWidth, mScreenHeight);
break;
// ...省略其他方向的,原理類似尘颓,具體看 demo...
}
// 設(shè)置 contentView 空白處的點擊事件走触,點擊消失,根據(jù)具體的滑動方向做不同的動畫效果
mContentView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
switch (mSlideType) {
case SLIDE_RIGHT_TO_LEFT:
rightInSmoothToRight();
break;
// ...省略其他方向的疤苹,原理類似互广,具體看 demo...
}
}
});
}
@Override
public void onFingerUp(int direction) {
// 當手指抬起來的時候,根據(jù)最后一下手勢卧土,即 direction 返回的值惫皱,判斷滑動的方向,選擇 contentView 是否顯示出來尤莺,還是回退不顯示旅敷,然后做不同的動畫
switch (mSlideType) {
// 右 -> 左
case SLIDE_RIGHT_TO_LEFT:
if (direction == FloatView.DIRECTION_LEFT) {
// 右側(cè)進入,滑到左側(cè)展開懸浮窗內(nèi)容的動畫
rightInSmoothToLeft();
} else {
// 右側(cè)進入颤霎,滑到右側(cè)回到初始狀態(tài)的動畫
rightInSmoothToRight();
}
break;
// ...省略其他方向的媳谁,原理類似,具體看 demo...
}
}
@Override
public void onScrollLeft(float scrollX) {
// 從右側(cè)滑到左側(cè)友酱,根據(jù)手勢滑動的距離晴音,不斷改變 left 的屬性值
if (mSlideType == SLIDE_RIGHT_TO_LEFT) {
mContentView.layout(mScreenWidth - (int) scrollX, 0, mScreenWidth, mScreenHeight);
mContentView.setVisibility(View.VISIBLE);
}
}
});
}
這里代碼會比較多,適當?shù)姆治鱿碌奚迹紫却冈辏斒种赴聪碌臅r候,需要先把 rootview 放到 windowManager 中或详,因為是透明的进苍,所以看上去就是桌面的內(nèi)容,然后需要去取得 contentView 并放到 rootview 上鸭叙,因為一開始不能顯示 contentView觉啊,所以設(shè)置 contentView 的不可見,然后沈贝,根據(jù) indicatorView 的位置杠人,設(shè)置 contentView 的位置屬性,例如 indicatorView 在屏幕的右側(cè),那么 contentView 也必須在屏幕的右側(cè)嗡善,當拖動 indicatorView 的時候再慢慢的顯示 contentView 就實現(xiàn)了拖動效果辑莫,所以 contentView 一開始 layout 的位置就是 (mScreenWidth, 0, mScreenWidth, mScreenHeight)
,其他的方向同理罩引。然后根據(jù)手勢的滑動方向和距離各吨,通過動畫不斷去改變 contentView 的 layout 屬性,并將 contentView 從不可見設(shè)置為可見袁铐,給用戶的感覺就有將懸浮窗一點點拖出來的效果了揭蜒。等到懸浮窗完全展示的時候,點擊空白的地方剔桨,懸浮窗又需要從當前的位置回滾到初始的位置屉更,其原理和拖出來的原理是一樣的。通過如上代碼可以發(fā)現(xiàn)洒缀,contentView 的 layout 屬性變化都是通過動畫來實現(xiàn)的瑰谜,這邊我采用屬性動畫,來不斷改變滑動的距離來實現(xiàn)懸浮窗顯示和隱藏的效果树绩,也就是就是上面代碼中的 rightInSmoothToLeft
和 rightInSmoothToRight
動畫的實現(xiàn)萨脑,不多解釋,直接上代碼和注釋
/**
* 右側(cè)滑進饺饭,滑到頁面左側(cè)渤早,進入動畫
*/
private void rightInSmoothToLeft() {
int posX = mScreenWidth - mContentView.getWidth();
// 通過屬性動畫做最后的效果,右側(cè)滑進到左側(cè)砰奕,contentView 的頁面從右側(cè)開始向左側(cè)滑動顯示蛛芥,那么 right 始終保持是屏幕的寬度不變提鸟,改變的是 left 屬性军援,
//從屏幕寬的值一直改變到 0,那屬性動畫的間隔就出來了称勋,時間設(shè)置整體的滑動為 300 ms胸哥,那么剩下的距離需要的滑動時間就是 300 * posX / mScreenWidth
ValueAnimator slideLeftAnim = ValueAnimator.ofInt(posX, 0).setDuration(300 * posX / mScreenWidth);
slideLeftAnim.setInterpolator(new AccelerateDecelerateInterpolator());
slideLeftAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
// 根據(jù)變化的值,重新設(shè)置 contentView 的布局
int pos = (int) animation.getAnimatedValue();
mContentView.layout(pos, 0, mScreenWidth, mScreenHeight);
}
});
slideLeftAnim.start();
}
/**
* 右側(cè)滑進赡鲜,滑到頁面右側(cè)空厌,退出動畫
*/
private void rightInSmoothToRight() {
int posX = mScreenWidth - mContentView.getWidth();
// 同理,退出的動畫就是 contentView 從當前的 left 的值一直變換到屏幕寬的值银酬,也可以得到相應(yīng)動畫
ValueAnimator slideRightAnim = ValueAnimator.ofInt(posX, mScreenWidth).setDuration(300 * (mScreenWidth - posX) / mScreenWidth);
slideRightAnim.setInterpolator(new AccelerateDecelerateInterpolator());
slideRightAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
// 根據(jù)變化的值嘲更,重新設(shè)置 contentView 的布局
int pos = (int) animation.getAnimatedValue();
mContentView.layout(pos, 0, mScreenWidth, mScreenHeight);
}
});
slideRightAnim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
// 退出動畫結(jié)束的時候,需要把 rootview 從 WindowManager 移除
dismissContentView();
}
});
slideRightAnim.start();
}
到現(xiàn)在為止揩瞪,我們已經(jīng)搞定所有的邏輯赋朦,就差將 indicatorView 顯示到視圖上就大功告成了,通過一個 show 方法將顯示的邏輯放到外部的 Activity 或者 Service 調(diào)用
/**
* 顯示 indicatorView
*/
public void show() {
try {
mWindowManager.updateViewLayout(mFloatView, mParams);
} catch (IllegalArgumentException e) {
mWindowManager.addView(mFloatView, mParams);
}
}
在調(diào)用 show 方法之前,如果版本大于 23 需要檢測懸浮窗權(quán)限才行宠哄,檢測的方法很簡單
public static boolean hasOverlayPermission(Context context) {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(context);
}
如果沒有同意懸浮窗權(quán)限壹将,則需要引導用戶打開,我這邊通過一個 dialog 實現(xiàn)引導
private void overlayPermissionRequest() {
mOverlayAskDialog = new AlertDialog.Builder(MainActivity.this)
.setTitle("Overlay Permission Request")
.setMessage("Need Overlay Permission")
.setPositiveButton("Yes", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName()));
startActivityForResult(intent, OVERLAY_PERMISSION_REQUEST);
}
mOverlayAskDialog.dismiss();
}
})
.setCancelable(false)
.create();
mOverlayAskDialog.show();
}
當跳轉(zhuǎn)權(quán)限頁面的時候最好用 startActivityForResult
方法毛嫉,當用戶從權(quán)限設(shè)置頁面回來的時候诽俯,通過 onActivityResult
方法再去檢測一次是否真正同意了權(quán)限,如果還是未同意承粤,那就再次引導用戶去同意權(quán)限暴区。這里附上 demo 的效果,雖然和實際項目的效果還是有差別密任,但是核心思想在這了
最后雙手捧上源碼 懸浮窗源碼颜启,望大爺笑納~