親,還在爲PopupWindow煩惱嗎

親,還在爲PopupWindow煩惱嗎


ps:預覽圖放到了文章最後java

這篇文章其實想寫好久了,然而一直以來總以爲BasePopup達不到本身的指望,因此也沒有怎麼去傳播推薦,也所以一直都沒有去寫文章,直到最近狠下心重構2.0版本,而且完善了wiki等api文檔後,才稍微滿意了點,所以纔開始着手寫下這篇文章。android

倉庫地址:github.com/razerdp/Bas…git

相比於star,我更在意您的issue~。程序員


現狀

跟很多產品經理、設計撕逼過的Android猿們應該都知道一件事:沒有什麼交互,是不能用彈窗解決的,若是有,就彈多一個。github

誠然,如何榨乾有限的屏幕空間同時又保持優雅的界面,是每一個交互設計都要去思考的事情。設計模式

這時候,他們每每會選擇一個神器:彈窗api

不管是從底部彈出仍是從中間彈出,亦或是上往下彈右往左彈,甚至是彈出的時候帶有動畫,變暗,模糊等交互,在彈窗上的花樣愈來愈多,而哭的,也每每是咱們程序員。。。ide

在Android中,爲了應付彈窗,咱們能夠選的東西其實挺多的:性能

  • Dialog
  • BottomSheetDialog
  • DialogFragment
  • PopupWindow
  • WindowManager直接懟入一個View
  • Dialog樣式的Activity
  • 等等等等....

不少時候,咱們都會選擇Dialog而不選擇PopupWindow,至於緣由,很簡單。。。PopupWindow好多坑!!!動畫

PopupWindow的優缺點

先說優勢,相比於Dialog,PopupWindow的位置比較隨意,能夠在任意位置顯示,而Dialog相對固定,其次就是背景變暗的效果,PopupWindow能夠輕鬆的定製背景,無需複雜的黑科技。

而缺點,也有不少,這也是爲何你們更偏向於Dialog的緣由,如下列舉幾條我認爲最顯著的缺點:

  • 建立複雜,與Dialog相比,每次都得寫模板化的那幾條初始化,很煩
  • 點擊事件的蛋疼,要麼沒法響應backpress,要麼點擊外部不消失(各個系統版本間的background問題
  • 系統版本的差別,每一次新系統的發佈,均可以發現PopupWindow也悄悄的有所改動,並且更坑的是,每每在修復了舊的bug後,又引入了新的問題(好比7.0高度match_parent時與之前顯示不一樣的問題
  • PopupWindow內沒法使用粘貼彈窗(這個是固有問題,由於粘貼那個功能彈窗也是PopupWindow,而PopupWindow內的View是沒法拿到windowToken的
  • 位置定位繁瑣

爲此,BasePopup庫就誕生了。

BasePopup解決方案

從1.0發佈到如今2.1.1(準備發佈2.1.2),爲了開發BasePopup,走過的坑和讀過的PopupWindow源碼能夠說是很是多了,固然,到如今爲止,都還有一些坑沒填,但BasePopup已經能夠適配大多數狀況了。

雖然這篇文章主要是推薦BasePopup,但更多的,是爲了跟你們分享一下個人解決Idea,一直以來都是我一我的維護這個庫,也沒有多少人跟我交流其中的實現要點,在這裏借這篇文章分享,同時也但願能獲得更多人的建議或批評。

建立複雜

首先咱們看看普通的PopupWindow寫法:

//ps,如下三句其實均可以合併成一句在構造方法裏,然而爲了防止內容過長,這裏分開寫
PopupWindow popupWindow = new PopupWindow(this);
popupWindow.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT);
popupWindow.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
popupWindow.setContentView(LayoutInflater.from(this).inflate(R.layout.layout_popupwindow, null));
popupWindow.setBackgroundDrawable(new ColorDrawable(0x00000000));
popupWindow.setOutsideTouchable(false);
popupWindow.setFocusable(true);
複製代碼

雖然上面打了個註釋解釋道上面有幾行是能夠合併到同一個構造方法裏解決,但PopupWindow有着5個以上的構造方法,即使有着IDE的自動提示,相信面對一大堆的構造方法依然是很頭疼吧。

在BasePopup裏,咱們只須要繼承BasePopupWindow並覆寫onCreateContentView方法返回您的contentView便可,對於外部來講,只須要寫兩行甚至是一行代碼就完成了。

new DemoPopup(getContext()).showPopupWindow();
複製代碼

也許你會說,這不更蛋疼了麼,爲了一個PopupWindow,我不得不寫多一個類。

這個問題就如MVP同樣,爲了更好地結構而不得不建立多一些類。。。

BasePopup之因此 寫成一個抽象類,除了更大程度的開放給開發者,更多的是讓開發者更好地把功能內聚到PopupWindow中,而不是去解決PopupWindow的各類蛋疼的坑。

固然,爲了知足一些簡單的PopupWindow實現而不但願又新建一個類,咱們也提供了懶懶的方法支持鏈式使用:

QuickPopupBuilder.with(getContext())
                .contentView(R.layout.popup_normal)
                .config(new QuickPopupConfig()
                        .gravity(Gravity.RIGHT | Gravity.CENTER_VERTICAL)
                        .withClick(R.id.tx_1, new View.OnClickListener() {
                            @Override
                            public void onClick(View v) {
                                Toast.makeText(getContext(), "clicked", Toast.LENGTH_LONG).show();
                            }
                        }))
                .show();
複製代碼

BasePopup是一個抽象類,具體實現交由子類(也就是開發者完成),同時也提供攔截器供開發者干預內部邏輯,最大化的開放自定義權限。

也許有更好的方法或設計模式,好比適配器等,這裏就不細說了。

相比於封裝相信您更關心其餘的實現。


事件消費

PopupWindow的事件一直都是讓人頭疼的事情,在6.0以前若是不設置background,那麼是沒法響應外部點擊事件,而在6.0以後又修復了這一問題。

致使這一事情發生的,實際上是跟PopupWindow內部的實現機制有關。

當咱們給PopupWindow設置一個contentView的時候,這一個contentView實際上是被PopupWindow內部的DecorView包裹住,而事件的響應則是由這個DecorView來分發。

在6.0以前,PopupWindow#preparePopup()源碼以下:

private void preparePopup(WindowManager.LayoutParams p) {
		//忽略部分代碼

        if (mBackground != null) {
            //忽略部分代碼,當background不爲空,才把contentView包裹進來
            PopupViewContainer popupViewContainer = new PopupViewContainer(mContext);
            PopupViewContainer.LayoutParams listParams = new PopupViewContainer.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT, height
            );
            popupViewContainer.setBackground(mBackground);
            popupViewContainer.addView(mContentView, listParams);

            mPopupView = popupViewContainer;
        } else {
            mPopupView = mContentView;
        }
			//忽略後面代碼
    }
複製代碼

而從6.0開始,preparePopup源碼以下:

private void preparePopup(WindowManager.LayoutParams p) {
		//忽略部分代碼
        if (mBackground != null) {
            mBackgroundView = createBackgroundView(mContentView);
            mBackgroundView.setBackground(mBackground);
        } else {
            mBackgroundView = mContentView;
        }
		//把contentView包裹到DecorView
        mDecorView = createDecorView(mBackgroundView);
        mDecorView.setIsRootNamespace(true);

        //忽略後面代碼
    }
複製代碼

對於PopupWindow的事件,是在內部DecorView的dispatchKeyEventonTouchEvent方法裏處理的,這裏就不貼源碼了。

因爲dispatchKeyEvent咱們沒法經過設置事件監聽去攔截,而PopupWindow的DecorView又沒法獲取,看起來事件的分發進入了一個死衚衕,然而經過細讀源碼,咱們找到了一個突破口:WindowManager

proxy WindowManager

PopupWindow沒有建立一個新的Window,它經過WindowManager添加一個新的View,其Type爲TYPE_APPLICATION_PANEL,所以PopupWindow須要windowToken來做爲依附。

在PopupWindow中,咱們的contentView被包裹進DecorView,而DecorView則是經過WindowManager添加到界面中。

因爲事件分發是在DecorView中,且沒有監聽器去攔截,所以咱們須要把這個DecorView再包多一層咱們自定義的控件,而後添加到Window中,這樣一來,DecorView就成了咱們的子類,對於事件的分發(甚至是measure/layout),咱們就有了絕對的控制權,BasePopup正是這樣作的。

然而,以上的步驟有個前提,就是如何代理掉WindowManager。(至關於尋找hook點)

在PopupWindow中,咱們經過讀源碼能夠獲知,PopupWindow中的WindowManager是在兩個地方被初始化:

  • 構造方法裏
  • setContentView()

所以,咱們也從這兩個地方入手,繼承PopupWindow並覆寫以上兩個方法,在裏面經過反射來獲取WindowManager並把它包裹到咱們的WindowManagerProxy裏面,而後再把咱們的WindowManagerProxy設置給PopupWindow,這樣就成功的偷天換日(代理)。

abstract class BasePopupWindowProxy extends PopupWindow {
    private static final String TAG = "BasePopupWindowProxy";

    private BasePopupHelper mHelper;
    private WindowManagerProxy mWindowManagerProxy;

    //構造方法皆有調用init(),此處忽略其餘構造方法

    public BasePopupWindowProxy(View contentView, int width, int height, boolean focusable, BasePopupHelper helper) {
        super(contentView, width, height, focusable);
        this.mHelper = helper;
        init(contentView.getContext());
    }

    void bindPopupHelper(BasePopupHelper mHelper) {
        if (mWindowManagerProxy == null) {
            tryToProxyWindowManagerMethod(this);
        }
        mWindowManagerProxy.bindPopupHelper(mHelper);
    }

    private void init(Context context) {
        setFocusable(true);
        setOutsideTouchable(true);
        setBackgroundDrawable(new ColorDrawable());
        tryToProxyWindowManagerMethod(this);
    }

    @Override
    public void setContentView(View contentView) {
        super.setContentView(contentView);
        tryToProxyWindowManagerMethod(this);
    }



    /** * 嘗試代理掉windowmanager * * @param popupWindow */
    private void tryToProxyWindowManagerMethod(PopupWindow popupWindow) {
        if (mHelper == null || mWindowManagerProxy != null) return;
        PopupLogUtil.trace("cur api >> " + Build.VERSION.SDK_INT);
        troToProxyWindowManagerMethodBeforeP(popupWindow);
    }

   // android p 以後的代理,須要使用黑科技
    private void troToProxyWindowManagerMethodOverP(PopupWindow popupWindow) {
        try {
            WindowManager windowManager = PopupReflectionHelper.getInstance().getPopupWindowManager(popupWindow);
            if (windowManager == null) return;
            mWindowManagerProxy = new WindowManagerProxy(windowManager);
            PopupReflectionHelper.getInstance().setPopupWindowManager(popupWindow, mWindowManagerProxy);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // android p 以前的代理,普通反射便可
    private void troToProxyWindowManagerMethodBeforeP(PopupWindow popupWindow) {
        try {
            Field fieldWindowManager = PopupWindow.class.getDeclaredField("mWindowManager");
            fieldWindowManager.setAccessible(true);
            final WindowManager windowManager = (WindowManager) fieldWindowManager.get(popupWindow);
            if (windowManager == null) return;
            mWindowManagerProxy = new WindowManagerProxy(windowManager);
            fieldWindowManager.set(popupWindow, mWindowManagerProxy);
            PopupLogUtil.trace(LogTag.i, TAG, "嘗試代理WindowManager成功");
        } catch (NoSuchFieldException e) {
            if (Build.VERSION.SDK_INT >= 27) {
                troToProxyWindowManagerMethodOverP(popupWindow);
            } else {
                e.printStackTrace();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}
複製代碼

說到反射,想必這裏就有人以爲會不會存在性能問題,說實話,我當初也有這個顧慮,但實際上,從ART以來,反射的性能影響其實已經下降了不少,同時咱們這裏並不是頻繁的反射,因此在這一點上我認爲能夠忽略。

另外反射獲取WindowManager在Android P或以上並不是在白名單中,所以BasePopup在這裏經過UnSafe來繞過Api調用的控制,該方法參考android_p_no_sdkapi_support,文章裏總結了幾種方法,本庫採起最後一種,具體的這裏就不細說了。

系統版本的差別及其餘問題

位置控制

系統版本致使的位置問題非常讓人頭疼,在以前我經過一個類來適配api24以前,api24,以及api24以後,後來發現越寫越多,所以產生了一個大膽的想法:

PopupWindow的位置,咱們本身來決定

因爲上面的代理,咱們對PopupWindow的DecorView有着絕對的控制,因此因爲系統版本致使PopupWindow顯示的問題也很好解決。

對於PopupWindow的位置,由於DecorView是咱們的自定義控件的子控件,所以在BasePopup中採起的方式是徹底重寫onLayout()

咱們的自定義控件是鋪滿整個屏幕的,所以咱們針對DecorView進行layout,在視覺上的效果就是這個PopupWindow顯示在了指定的位置上(背景透明,而contentView是用戶指定的xml,通常有顏色),但實際上PopupWindow是鋪滿整個屏幕的。

(固然,對於普通的使用,也就PopupWindow不鋪滿整個屏幕也有適配)

如下是layout的部分代碼:

private void layoutWithIntercept(int l, int t, int r, int b) {
        final int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == GONE) continue;
            int width = child.getMeasuredWidth();
            int height = child.getMeasuredHeight();

            int gravity = mHelper.getPopupGravity();

            int childLeft = child.getLeft();
            int childTop = child.getTop();

            int offsetX = mHelper.getOffsetX();
            int offsetY = mHelper.getOffsetY();

            boolean delayLayoutMask = mHelper.isAlignBackground();

            boolean keepClipScreenTop = false;

            if (child == mMaskLayout) {
                child.layout(childLeft, childTop, childLeft + width, childTop + height);
            } else {
                boolean isRelativeToAnchor = mHelper.isShowAsDropDown();
                int anchorCenterX = mHelper.getAnchorX() + (mHelper.getAnchorViewWidth() >> 1);
                int anchorCenterY = mHelper.getAnchorY() + (mHelper.getAnchorHeight() >> 1);
                //不跟anchorView聯繫的狀況下,gravity意味着在整個view中的方位
                //若是跟anchorView聯繫,gravity意味着以anchorView爲中心的方位
                switch (gravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
                    case Gravity.LEFT:
                    case Gravity.START:
                        if (isRelativeToAnchor) {
                            childLeft = mHelper.getAnchorX() - width + childLeftMargin;
                        } else {
                            childLeft += childLeftMargin;
                        }
                        break;
                    case Gravity.RIGHT:
                    case Gravity.END:
                        if (isRelativeToAnchor) {
                            childLeft = mHelper.getAnchorX() + mHelper.getAnchorViewWidth() + childLeftMargin;
                        } else {
                            childLeft = getMeasuredWidth() - width - childRightMargin;
                        }
                        break;
                    case Gravity.CENTER_HORIZONTAL:
                        if (isRelativeToAnchor) {
                            childLeft = mHelper.getAnchorX();
                            offsetX += anchorCenterX - (childLeft + (width >> 1));
                        } else {
                            childLeft = ((r - l - width) >> 1) + childLeftMargin - childRightMargin;
                        }
                        break;
                    default:
                        if (isRelativeToAnchor) {
                            childLeft = mHelper.getAnchorX() + childLeftMargin;
                        }
                        break;
                }

                switch (gravity & Gravity.VERTICAL_GRAVITY_MASK) {
                    case Gravity.TOP:
                        if (isRelativeToAnchor) {
                            childTop = mHelper.getAnchorY() - height + childTopMargin;
                        } else {
                            childTop += childTopMargin;
                        }
                        break;
                    case Gravity.BOTTOM:
                        if (isRelativeToAnchor) {
                            keepClipScreenTop = true;
                            childTop = mHelper.getAnchorY() + mHelper.getAnchorHeight() + childTopMargin;
                        } else {
                            childTop = b - t - height - childBottomMargin;
                        }
                        break;
                    case Gravity.CENTER_VERTICAL:
                        if (isRelativeToAnchor) {
                            childTop = mHelper.getAnchorY() + mHelper.getAnchorHeight();
                            offsetY += anchorCenterY - (childTop + (height >> 1));
                        } else {
                            childTop = ((b - t - height) >> 1) + childTopMargin - childBottomMargin;
                        }
                        break;
                    default:
                        if (isRelativeToAnchor) {
                            keepClipScreenTop = true;
                            childTop = mHelper.getAnchorY() + mHelper.getAnchorHeight() + childTopMargin;
                        } else {
                            childTop += childTopMargin;
                        }
                        break;
                }

                int left = childLeft + offsetX;
                int top = childTop + offsetY + (mHelper.isFullScreen() ? 0 : -getStatusBarHeight());
                int right = left + width;
                int bottom = top + height;

                //針對clipToScreen和autoLocated的狀況,這裏因篇幅限制忽略
                }
                child.layout(left, top, right, bottom);
                if (delayLayoutMask) {
                    mMaskLayout.handleAlignBackground(left, top, right, bottom);
                }
            }

        }
    }
複製代碼

對於layout,咱們只須要區分PopupWindow是否跟anchorView關聯,而後根據Gravity和Offset進行位置的計算。

這些操做對於常常自定義控件的同窗來講簡直就是拈手即來。

而對於平時的PopupWindow用法,即PopupWindow不鋪滿整個屏幕,在BasePopup中則是跟普通用法同樣計算offset。

private void onCalculateOffsetAdjust(View anchorView, Point offset) {
        if (anchorView != null) {
            //因爲showAsDropDown系統已經幫咱們定位在view的下方,所以這裏的offset咱們僅須要作微量偏移

            switch (getPopupGravity() & Gravity.HORIZONTAL_GRAVITY_MASK) {
                case Gravity.LEFT:
                case Gravity.START:
                    offset.x += -getWidth();
                    break;
                case Gravity.RIGHT:
                case Gravity.END:
                    offset.x += mHelper.getAnchorViewWidth();
                    break;
                case Gravity.CENTER_HORIZONTAL:
                    offset.x += (mHelper.getAnchorViewWidth() - getWidth()) >> 1;
                    break;
                default:
                    break;
            }

            switch (getPopupGravity() & Gravity.VERTICAL_GRAVITY_MASK) {
                case Gravity.TOP:
                    offset.y += -(mHelper.getAnchorHeight() + getHeight());
                    break;
                case Gravity.BOTTOM:
                    //系統默認就在下面.
                    break;
                case Gravity.CENTER_VERTICAL:
                    offset.y += -((getHeight() + mHelper.getAnchorHeight()) >> 1);
                    break;
                default:
                    break;
            }
        }
    }
複製代碼

正由於位置有咱們來控制,因此不只僅在全部版本中統一了位置的計算方式,並且更重要的是,PopupWindow的Gravity這一個屬性被充分使用,不再用去計算心塞的偏移量了。

舉個例子,好比咱們要顯示在某個 view的右邊,同時本身跟他垂直對齊。

在系統的PopupWindow中,你可能要這麼寫:

//前面忽略建立方法
popup.showAsDropDown(v,v.getWidth(),-(v.getHeight()+popup.getHeight())>>1)
複製代碼

上面的代碼仍是比較簡單的,popup默認顯示在anchorView的下方,此處須要計算偏移量,使popup能夠偏移到view的右方,可是有個值得關注的是popup在顯示以前是獲取不到正確的contentView的寬高的。

而在BasePopup中,你要寫的,僅僅是這樣:

//前面忽略建立方法
popup.setPopupGravity(Gravity.RIGHT|Gravity.CENTER_VERTICAL);
popup.showPopupWindow(anchorView);
複製代碼

在BasePopup中,由於layout由咱們接管,所以在onLayout中咱們實際上是知道contentView的寬高,所以根據上面的代碼,咱們直接經過Gravity來計算出Popup的正確位置便可。

關於Gravity的Demo

背景模糊

同時咱們能夠針對這個自定義的ViewGroup默認添加背景,在BasePopup中,背景添加了一個ImageView和一個View,分別處理模糊和背景顏色。

其中背景的模糊採起的是RenderScript,對於不支持的狀況則採起fastBlur,因爲模糊基本上大同小異,在這裏就不貼代碼了。

其餘問題

到目前位置,BasePopup知足多數的PopupWindow使用,但仍然有不足,好比沒有支持PopupWindow的update()方法,由於咱們多數時候PopupWindow都是展現用,並且基本上都是展現一次後就消掉。

但不排除有PopupWindow跟隨某個View而更新本身的位置這一需求,所以在接下來的維護裏,這個問題將會歸入到以後的工做中。

最後感謝提issue的小夥伴們,大家的每個issue我都認真的看且有空就去清掉。

最後的最後,但願本文能對看到這篇文章的你有些幫助~

thanks

倉庫地址:github.com/razerdp/Bas…


18/12/19:candy版本更新到2.1.3-alpha,已經支持update~感謝支持

預覽圖:

anchorView綁定 不一樣方向彈出
任意位置顯示 參考anchorView更新
從下方彈出並模糊背景 朋友圈評論彈窗
相關文章
相關標籤/搜索