ViewDragHelper實戰:APP內“懸浮球”
前言
“懸浮球”最初是iPhone手機上的一個虛擬按鍵,它會懸浮于所有APP之上,手指隨意拖動,松開后會自動貼邊顯示。現在滿大街都是iPhone手機,相信大家都用過或者看過這個效果,這里就不上圖了~
當前,很多Android手機也都有了這個功能,并且很多第三方APP也實現了此功能,比如某垃圾清理軟件。可能大家立馬就會想到,這個不就是使用 WindowManager 實現的 懸浮窗 ,然后在 onTouch 事件里面根據手指的移動來改變位置嗎?
確實,如果你的 “懸浮球” 是在桌面,實現方案的確如此(也只能如此)。但是,本文需要實現的是 應用內“懸浮球” ,即:退出應用不需要顯示,并且我們不希望使用 android.permission.SYSTEM_ALERT_WINDOW 這個權限,要知道Android M 6.0此權限屬于危險權限,需要動態申請授權后才能使用,且使用 WindowManager 實現 懸浮窗 “必須” ( 此處有引號~ )使用此權限。
上文的 “必須” 加引號的原因:WindowManager特定情況是可以無權限顯示懸浮框的,但這不是本文討論的范疇,總結來說,無權限的坑還是很多~
效果圖
下面的效果圖,是一款線上App新版即將發布的功能。
可以看到, “懸浮球” 在App內所有界面都 “獨立” 顯示,每個界面都支持拖動并 自動貼邊 ,且所有界面的 “懸浮球” 位置都保持一致。
實現步驟
我們將“懸浮球”實現步驟分解為以下幾步:
- 屏幕范圍內任意位置拖動
- 釋放后自動貼邊
- 解決UI刷新,恢復到原始位置的問題
- 提供統一入口給所有Activity
- 所有Activity保持“實時”位置一致
下面,我們就每個步驟進行分別講解:
一、屏幕范圍內任意位置拖動
我們在 Android自定義ViewGroup神器-ViewDragHelper 一文中已經做過詳細的講解,通過重寫 ViewDragHelper.Callback 的以下方法實現:
-
tryCaptureView 判斷 View 是否是我們要拖動的
@Override public boolean tryCaptureView(View child, int pointerId) { return child == floatingBtn; }
-
clampViewPositionHorizontal 和 clampViewPositionVertical ,返回水平和垂直方向可移動的范圍
@Override public int clampViewPositionVertical(View child, int top, int dy) { if (top > getHeight() - child.getMeasuredHeight()) { top = getHeight() - child.getMeasuredHeight(); } else if (top < 0) { top = 0; } return top; }
@Override public int clampViewPositionHorizontal(View child, int left, int dx) { if (left > getWidth() - child.getMeasuredWidth()) { left = getWidth() - child.getMeasuredWidth(); } else if (left < 0) { left = 0; } return left; }</code></pre> </li>
如果可拖動的 View 是可點擊的(Button or 其他), getViewHorizontalDragRange 和 getViewVerticalDragRange 需要返回水平和垂直可移動的范圍
@Override public int getViewVerticalDragRange(View child) { return getMeasuredHeight() - child.getMeasuredHeight(); }
@Override public int getViewHorizontalDragRange(View child) { return getMeasuredWidth() - child.getMeasuredWidth(); }</code></pre> </li> </ol>
二、釋放后自動貼邊
需要監聽手指“釋放”被拖拽 View 的事件,可以重寫 ViewDragHelper.Callback 的 onViewReleased 方法。
我們觀察下,自動貼邊是根據當前 View 所在的區域,決定貼在哪一個方向。這個是和產品的需求有關,以下代碼僅供參考:
@Override public void onViewReleased(View releasedChild, float xvel, float yvel) { if (releasedChild == floatingBtn) { float x = floatingBtn.getX(); float y = floatingBtn.getY(); if (x < (getMeasuredWidth() / 2f - releasedChild.getMeasuredWidth() / 2f)) { // 0-x/2 if (x < releasedChild.getMeasuredWidth() / 3f) { x = 0; } else if (y < (releasedChild.getMeasuredHeight() * 3)) { // 0-y/3 y = 0; } else if (y > (getMeasuredHeight() - releasedChild.getMeasuredHeight() * 3)) { // 0-(y-y/3) y = getMeasuredHeight() - releasedChild.getMeasuredHeight(); } else { x = 0; } } else { // x/2-x if (x > getMeasuredWidth() - releasedChild.getMeasuredWidth() / 3f - releasedChild.getMeasuredWidth()) { x = getMeasuredWidth() - releasedChild.getMeasuredWidth(); } else if (y < (releasedChild.getMeasuredHeight() * 3)) { // 0-y/3 y = 0; } else if (y > (getMeasuredHeight() - releasedChild.getMeasuredHeight() * 3)) { // 0-(y-y/3) y = getMeasuredHeight() - releasedChild.getMeasuredHeight(); } else { x = getMeasuredWidth() - releasedChild.getMeasuredWidth(); } } // 移動到x,y dragHelper.smoothSlideViewTo(releasedChild, (int) x, (int) y); invalidate(); } }
根據你的產品的需求(上面模仿了iPhone的懸浮球),計算好最終的 x 和 y ,然后使用 ViewDragHelper 的 smoothSlideViewTo 方法,將 View 移動到指定位置。
三、解決UI刷新,恢復到原始位置的問題
這個問題在做Demo的時候并沒有遇到,但當集成到項目中的時候,就出現了這個問題,如下圖:
move
首頁點擊某個 Item 展開( ExpandableListView )或者切換底部 Tab ( Fragment 顯示與隱藏),“懸浮球”會恢復到原始的位置,我們來分析下為什么?
我們先來簡單分析下 ViewDragHelper 的部分源碼實現。
從 smoothSlideViewTo 這個方法切入,該方法內部的實現如下:
smoothSlideViewTo
545行, forceSettleCapturedViewAt 方法
forceSettleCapturedViewAt
600行,使用 Scroller 來實現 View 的位置滑動,熟悉 Scroller 的同學應該都知道,需要在自定義 ViewGroup 的 computeScroll 方法做處理
@Override public void computeScroll() { if (dragHelper.continueSettling(true)) { invalidate(); } }
關鍵代碼在 if 語句的 continueSettling 方法:
continueSettling
733、736行,使用 offsetLeftAndRight 和 offsetTopAndBottom 來設置 View 的位置,這個方法與 View 的 setX 和 setY 方法有異曲同工之效。
通過這種方式,的確是真實改變了 View 的 x 和 y 坐標。但是,當UI刷新后,我們自定義的 ViewGroup 的 onMeasure 、 onLayout 等方法會被調用,我們都知道 onLayout 方法直接決定了子 View 的位置。
但是 onLayout 方法是不會根據子 View 的 x 和 y 來排列它的位置,而是根據 LayoutParams 來決定,關鍵源碼如下:
@Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { layoutChildren(left, top, right, bottom, false / no force left gravity /); }
void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) { final int count = getChildCount();
final int parentLeft = getPaddingLeftWithForeground(); final int parentRight = right - left - getPaddingRightWithForeground(); final int parentTop = getPaddingTopWithForeground(); final int parentBottom = bottom - top - getPaddingBottomWithForeground(); for (int i = 0; i < count; i++) { final View child = getChildAt(i); if (child.getVisibility() != GONE) { final LayoutParams lp = (LayoutParams) child.getLayoutParams(); ... final int width = child.getMeasuredWidth(); final int height = child.getMeasuredHeight(); ... int childLeft; int childTop; ... childLeft = parentLeft + lp.leftMargin; ... childTop = parentTop + lp.topMargin; ... child.layout(childLeft, childTop, childLeft + width, childTop + height); } }
}</code></pre>
所以,我們的解決方案很簡單,就是重寫 ViewGroup 的 onLayout 方法,設置被拖拽 View 的位置:
@Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); restorePosition(); }
// 記錄最后的位置 float mLastX = -1; float mLastY = -1; public void restorePosition() { if (mLastX == -1 && mLastY == -1) { // 初始位置 mLastX = getMeasuredWidth() - floatingBtn.getMeasuredWidth(); mLastY = getMeasuredHeight() 2 / 3; } floatingBtn.layout((int)mLastX, (int)mLastY, (int)mLastX + floatingBtn.getMeasuredWidth(), (int)mLastY + floatingBtn.getMeasuredHeight()); }</code></pre>
mLastX 和 mLastY 是用來記錄“懸浮球”最后的位置,需要在 ViewDragHelper.Callback 的 onViewPositionChanged 方法中處理
@Override public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) { super.onViewPositionChanged(changedView, left, top, dx, dy); mLastX = changedView.getX(); mLastY = changedView.getY(); }
只要“懸浮球”的位置發生變化,就會回調這個方法。
四、提供統一入口給所有Activity
基本所有項目都會有一個 BaseActivity (如果沒有,只能呵呵了~),重寫 setContentView 方法,統一接入我們的“懸浮球”:
public class BaseActivity extends AppCompatActivity{ ... @Override public void setContentView(int layoutResID) { super.setContentView(new FloatingDragger(this, layoutResID).getView()); } ... }
這樣,所有 Activity 的代碼可以保持不變,只要繼承自 BaseActivity ,就會擁有“懸浮球”功能,所有業務全部封裝在 FloatingDragger 這個類中。
五、所有Activity保持“實時”位置一致
FloatingDragger 這個類,實際上是在 Activity 原有的布局 layoutResID 之上添加了一個 View ,也就是我們的“懸浮球”,所以每個 Activity 都擁有一個不同的 FloatingDragger 對象。
我們可以實時保存“懸浮球”的位置,這樣每次重新打開APP,“懸浮球”總會在上次的位置。如果進入下一個 Activity2 ,它的位置也總是和上一個 Activity1 一致。這個實現比較簡單,將上文的 mLastX 和 mLastY 存儲到配置文件
@Override public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) { super.onViewPositionChanged(changedView, left, top, dx, dy); int x = changedView.getX(); int y = changedView.getY(); spdbHelper.putFloat(KEY_FLOATING_X, x); spdbHelper.putFloat(KEY_FLOATING_Y, y); }
然后位置從配置文件讀取
public void restorePosition() { float x = spdbHelper.getFloat(KEY_FLOATING_X, -1); float y = spdbHelper.getFloat(KEY_FLOATING_Y, -1); if (x == -1 && y == -1) { // 初始位置 x = getMeasuredWidth() - floatingBtn.getMeasuredWidth(); y = getMeasuredHeight()
但是,如果你在 Activity2 改變了位置,怎么讓 Activity1 “懸浮球”的位置也刷新呢?
這里有兩種方案:
- BaseActivity 的 onResume 調用 FloatingDragger 對象的某個方法
- FloatingDragger 內部實現
方法1比較簡單,這里不做演示。另外,顯然方案2也更好一點,因為和 Activity 的耦合度更低,比較符合“封裝”的思想。
我們思考下, FloatingDragger 對所有“懸浮球”位置的改變感興趣,似乎比較符合設計模式中的 觀察者模式 , FloatingDragger 是 觀察者 , 被觀察者 是一個單例 PositionObservable ,“懸浮球”位置發生變化后通過 PositionObservable 通知所有的 FloatingDragger 對象。
被觀察者:
public class PositionObservable extends Observable { public static PositionObservable sInstance; public static PositionObservable getInstance() { if (sInstance == null) { sInstance = new PositionObservable(); } return sInstance; }
/** * 通知觀察者FloatingDragger */ public void update() { setChanged(); notifyObservers(); }
}</code></pre>
觀察者:
public class FloatingDragger implements Observer { PositionObservable observable = PositionObservable.getInstance(); FloatingDraggedView floatingDraggedView;
public FloatingDragger(Context context, @LayoutRes int layoutResID) { // 用戶布局 View contentView = LayoutInflater.from(context).inflate(layoutResID, null); // 懸浮球按鈕 View floatingView = LayoutInflater.from(context).inflate(R.layout.layout_floating_dragged, null); // ViewDragHelper的ViewGroup容器 floatingDraggedView = new FloatingDraggedView(context); floatingDraggedView.addView(contentView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); floatingDraggedView.addView(floatingView, new FrameLayout.LayoutParams(APKUtil.dip2px(context, 45), APKUtil.dip2px(context, 40))); // 添加觀察者 observable.addObserver(this); } .... @Override public void update(Observable o, Object arg) { if (floatingDraggedView != null) { // 更新位置 floatingDraggedView.restorePosition(); } } public class FloatingDraggedView extends FrameLayout { ... public FloatingDraggedView(Context context) { super(context); init(); } void init() { dragHelper = ViewDragHelper.create(FloatingDraggedView.this, 1.0f, new ViewDragHelper.Callback() { @Override public void onViewDragStateChanged(int state) { super.onViewDragStateChanged(state); if (state == ViewDragHelper.STATE_SETTLING) { // 拖拽結束,通知觀察者 observable.update(); } } ... } } ...
} ... }</code></pre>
ViewDragHelper.Callback 的 onViewDragStateChanged 方法,在 View 被拖動的時候會回調三次,分別對應三個狀態
- STATE_IDLE:空閑
- STATE_DRAGGING:正在拖拽
- STATE_SETTLING:拖拽結束,放置View
寫在最后
2016年轉眼就要過去了,回憶這一年,自己從一家外包公司,跳槽到一家創業公司。以前在外包公司職責是移動端負責人(數十人的移動團隊),外包公司項目的周期非常短,壓力非常大,移動端一年至少8-10個項目,自己也是全程參與Android端的代碼開發,同時還要負責業務以及和后臺的API對接工作,另外還要管理iOS團隊(因為精力問題,這點做的不是太合格,需要檢討)。
另外,經常還要和銷售一起出去面對客戶談需求,不得不說外包公司雖然累了點,但是做為過來人,我還是要告訴大家, “公司是別人的,學到東西才是自己的” 。所以,剛畢業的小伙伴,或者正在找工作的同學,沒有必要太“歧視”或看不上外包公司,畢竟學習技術還是要靠自己,再好的公司,你如果是一顆螺絲釘還不如在小公司多負責、多做點東西,這樣才能在工作中成長,在學習中進步。
來自:http://www.jianshu.com/p/d2c80e7e584e