1. 程式人生 > >親,還在為PopupWindow煩惱嗎

親,還在為PopupWindow煩惱嗎

親,還在為PopupWindow煩惱嗎


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

本文已經授權鴻洋公眾號轉載

這篇文章其實想寫很久了,然而一直以來總覺得BasePopup達不到自己的期望,所以也沒有怎麼去傳播推薦,也因此一直都沒有去寫文章,直到最近狠下心重構2.0版本,並且完善了wiki等api文件後,才稍微滿意了點,因此才開始著手寫下這篇文章。

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

相比於star,我更在乎您的issue~。


現狀

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

誠然,如何榨乾有限的螢幕空間同時又保持優雅的介面,是每個互動設計都要去思考的事情。

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

無論是從底部彈出還是從中間彈出,亦或是上往下彈右往左彈,甚至是彈出的時候帶有動畫,變暗,模糊等互動,在彈窗上的花樣越來越多,而哭的,也往往是我們程式設計師。。。

在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;
            }
        }
    }
複製程式碼
背景模糊

同時我們可以針對這個自定義的ViewGroup預設新增背景,在BasePopup中,背景添加了一個ImageView和一個View,分別處理模糊和背景顏色。

其中背景的模糊採取的是RenderScript,對於不支援的情況則採取fastBlur,由於模糊基本上大同小異,在這裡就不貼程式碼了。

其他問題

到目前位置,BasePopup滿足多數的PopupWindow使用,但仍然有不足,比如沒有支援PopupWindow的update()方法,因為我們多數時候PopupWindow都是展示用,而且基本上都是展示一次後就消掉。

但不排除有PopupWindow跟隨某個View而更新自己的位置這一需求,因此在接下來的維護裡,這個問題將會納入到之後的工作中。

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

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

thanks

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

預覽圖:

GravityPopupFrag LocatePopupFrag
BlurSlideFromBottomPopupFrag CommentPopup