ViewDragHelper實戰:APP內“懸浮球”

Cha7096 7年前發布 | 11K 次閱讀 安卓開發 Android開發 移動開發

前言

“懸浮球”最初是iPhone手機上的一個虛擬按鍵,它會懸浮于所有APP之上,手指隨意拖動,松開后會自動貼邊顯示。現在滿大街都是iPhone手機,相信大家都用過或者看過這個效果,這里就不上圖了~

當前,很多Android手機也都有了這個功能,并且很多第三方APP也實現了此功能,比如某垃圾清理軟件。可能大家立馬就會想到,這個不就是使用 WindowManager 實現的 懸浮窗 ,然后在 onTouch 事件里面根據手指的移動來改變位置嗎?

確實,如果你的 “懸浮球” 是在桌面,實現方案的確如此(也只能如此)。但是,本文需要實現的是 應用內“懸浮球” ,即:退出應用不需要顯示,并且我們不希望使用 android.permission.SYSTEM_ALERT_WINDOW 這個權限,要知道Android M 6.0此權限屬于危險權限,需要動態申請授權后才能使用,且使用 WindowManager 實現 懸浮窗 “必須” ( 此處有引號~ )使用此權限。

上文的 “必須” 加引號的原因:WindowManager特定情況是可以無權限顯示懸浮框的,但這不是本文討論的范疇,總結來說,無權限的坑還是很多~

效果圖

下面的效果圖,是一款線上App新版即將發布的功能。

可以看到, “懸浮球” 在App內所有界面都 “獨立” 顯示,每個界面都支持拖動并 自動貼邊 ,且所有界面的 “懸浮球” 位置都保持一致。

實現步驟

我們將“懸浮球”實現步驟分解為以下幾步:

  1. 屏幕范圍內任意位置拖動
  2. 釋放后自動貼邊
  3. 解決UI刷新,恢復到原始位置的問題
  4. 提供統一入口給所有Activity
  5. 所有Activity保持“實時”位置一致

下面,我們就每個步驟進行分別講解:

一、屏幕范圍內任意位置拖動

我們在 Android自定義ViewGroup神器-ViewDragHelper 一文中已經做過詳細的講解,通過重寫 ViewDragHelper.Callback 的以下方法實現:

  1. tryCaptureView 判斷 View 是否是我們要拖動的

    @Override
    public boolean tryCaptureView(View child, int pointerId) {    
       return child == floatingBtn;
    }
  2. 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>

  3. 如果可拖動的 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()  2 / 3;
        }
        floatingBtn.layout((int)x, (int)y,
                    (int)x + floatingBtn.getMeasuredWidth(), (int)y + floatingBtn.getMeasuredHeight());
    }</code></pre> 
      

    但是,如果你在 Activity2 改變了位置,怎么讓 Activity1 “懸浮球”的位置也刷新呢?

    這里有兩種方案:

    1. BaseActivity 的 onResume 調用 FloatingDragger 對象的某個方法
    2. 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

     

 本文由用戶 Cha7096 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!