【Window系列】——PopupWindow的前世今生

本系列博客基于android-28版本
【Window系列】——Toast源碼解析
【Window系列】——PopupWindow的前世今生
【Window系列】——Dialog源碼解析
【Window系列】——Window中的Token

前言

上一篇博客分析了Toast的源碼,一提到Window必然少不了本篇博客分析的PopupWindow,本來我以為是一樣的流程,創建Window,設置View到DecorView,加入Window,完事兒...但卻發現PopupWindow卻沒有按照這種實現方式實現的。

大綱

本篇博客會分析一下幾點:

  1. PopupWindow的實現原理源碼
  2. PopupWindow關于BackgroundDrawable的版本差異導致的問題
  3. PopupWindow的觸摸事件處理

源碼分析

我們平時使用PopupWindow主要涉及以下三個核心方法:

PopupWindow window = new PopupWindow();
window.setContentView(...);
window.showAsDropDown(...);

所以首先看一下構造函數

public PopupWindow(View contentView, int width, int height, boolean focusable) {
        if (contentView != null) {
            mContext = contentView.getContext();
            mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
        }

        setContentView(contentView);
        setWidth(width);
        setHeight(height);
        setFocusable(focusable);
    }

如果在構造函數設置了ContentView,那么直接獲取Context對象和WindowManager,調用setContentView方法,設置寬高,和Focusable,這里要注意一下Focusable這個變量,后面會講到這個變量在PopupWindow中的作用。
如果我們調用的是最基礎的構造函數,一般我們下一步會調用setContentView方法設置我們的布局,那么這里我們就來看一下這個方法。

public void setContentView(View contentView) {
        if (isShowing()) {
            return;
        }
        //保存ContentView
        mContentView = contentView;

        if (mContext == null && mContentView != null) {
            mContext = mContentView.getContext();
        }

        if (mWindowManager == null && mContentView != null) {
            mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
        }

        // Setting the default for attachedInDecor based on SDK version here
        // instead of in the constructor since we might not have the context
        // object in the constructor. We only want to set default here if the
        // app hasn't already set the attachedInDecor.
        if (mContext != null && !mAttachedInDecorSet) {
            // Attach popup window in decor frame of parent window by default for
            // {@link Build.VERSION_CODES.LOLLIPOP_MR1} or greater. Keep current
            // behavior of not attaching to decor frame for older SDKs.
            setAttachedInDecor(mContext.getApplicationInfo().targetSdkVersion
                    >= Build.VERSION_CODES.LOLLIPOP_MR1);
        }

    }

可以看到,和剛才看到的構造函數基本相同,保存了ContentView變量后,獲取ContextWindowManger對象。
可以看到上面兩個步驟基本上都是做的準備工作,那么接下來看一下最核心的展示方法showAsDropDown

public void showAsDropDown(View anchor) {
        showAsDropDown(anchor, 0, 0);
    }
    
public void showAsDropDown(View anchor, int xoff, int yoff, int gravity) {
        if (isShowing() || !hasContentView()) {
            return;
        }

        TransitionManager.endTransitions(mDecorView);
        //綁定監聽,設置變量
        attachToAnchor(anchor, xoff, yoff, gravity);

        mIsShowing = true;
        mIsDropdown = true;
        //創建布局參數
        final WindowManager.LayoutParams p =
                createPopupLayoutParams(anchor.getApplicationWindowToken());
        //包裹布局,構建布局層級
        preparePopup(p);

        final boolean aboveAnchor = findDropDownPosition(anchor, p, xoff, yoff,
                p.width, p.height, gravity, mAllowScrollingAnchorParent);
        updateAboveAnchor(aboveAnchor);
        p.accessibilityIdOfAnchor = (anchor != null) ? anchor.getAccessibilityViewId() : -1;
        //添加布局到Window中
        invokePopup(p);
    }

可以看到,這個方法其實還是利用了重載,實現了很多方法,最終都是到了最后這個方法里。
上面大概分了四部分,我分別寫了注釋,這里來單獨看一下。

protected void attachToAnchor(View anchor, int xoff, int yoff, int gravity) {
        detachFromAnchor();

        final ViewTreeObserver vto = anchor.getViewTreeObserver();
        if (vto != null) {
            vto.addOnScrollChangedListener(mOnScrollChangedListener);
        }
        anchor.addOnAttachStateChangeListener(mOnAnchorDetachedListener);

        final View anchorRoot = anchor.getRootView();
        anchorRoot.addOnAttachStateChangeListener(mOnAnchorRootDetachedListener);
        anchorRoot.addOnLayoutChangeListener(mOnLayoutChangeListener);
        //弱引用
        mAnchor = new WeakReference<>(anchor);
        mAnchorRoot = new WeakReference<>(anchorRoot);
        mIsAnchorRootAttached = anchorRoot.isAttachedToWindow();
        mParentRootView = mAnchorRoot;

        mAnchorXoff = xoff;
        mAnchorYoff = yoff;
        mAnchoredGravity = gravity;
    }

可以看到這個方法主要是設置我們傳入到參數的,但是這里要注意的是Google在這里使用了弱引用,這個我感覺是比較少見的,目前我所了解的FrameWork層的源碼里,很少看到Google使用弱引用,這里利用弱引用保存了傳入的布局和頂層父布局。

protected final WindowManager.LayoutParams createPopupLayoutParams(IBinder token) {
        final WindowManager.LayoutParams p = new WindowManager.LayoutParams();

        // These gravity settings put the view at the top left corner of the
        // screen. The view is then positioned to the appropriate location by
        // setting the x and y offsets to match the anchor's bottom-left
        // corner.
        p.gravity = computeGravity();
        p.flags = computeFlags(p.flags);
        p.type = mWindowLayoutType;
        //設置Token
        p.token = token;
        p.softInputMode = mSoftInputMode;
        //設置動畫
        p.windowAnimations = computeAnimationResource();

        if (mBackground != null) {
            p.format = mBackground.getOpacity();
        } else {
            p.format = PixelFormat.TRANSLUCENT;
        }
        //設置寬高
        if (mHeightMode < 0) {
            p.height = mLastHeight = mHeightMode;
        } else {
            p.height = mLastHeight = mHeight;
        }

        if (mWidthMode < 0) {
            p.width = mLastWidth = mWidthMode;
        } else {
            p.width = mLastWidth = mWidth;
        }

        p.privateFlags = PRIVATE_FLAG_WILL_NOT_REPLACE_ON_RELAUNCH
                | PRIVATE_FLAG_LAYOUT_CHILD_WINDOW_IN_PARENT_FRAME;

        // Used for debugging.
        p.setTitle("PopupWindow:" + Integer.toHexString(hashCode()));

        return p;
    }

createPopupLayoutParams是用來創建一個LayoutParam,這里注重注意一下token這個變量,看過前一篇博客的應該都記得,Toast組件也需要一個token變量,這里這個token可以看到是用anchor.getApplicationWindowToken()獲取的,也就是父布局的token。關于token后面會抽出一篇博客來專門分析一下,token對于Window類型的影響。

private void preparePopup(WindowManager.LayoutParams p) {
        if (mContentView == null || mContext == null || mWindowManager == null) {
            throw new IllegalStateException("You must specify a valid content view by "
                    + "calling setContentView() before attempting to show the popup.");
        }

        if (p.accessibilityTitle == null) {
            p.accessibilityTitle = mContext.getString(R.string.popup_window_default_title);
        }

        // The old decor view may be transitioning out. Make sure it finishes
        // and cleans up before we try to create another one.
        if (mDecorView != null) {
            mDecorView.cancelTransitions();
        }

        // When a background is available, we embed the content view within
        // another view that owns the background drawable.
        //設置Background包裹
        if (mBackground != null) {
            mBackgroundView = createBackgroundView(mContentView);
            mBackgroundView.setBackground(mBackground);
        } else {
            mBackgroundView = mContentView;
        }
        //再用DecorView包裹
        mDecorView = createDecorView(mBackgroundView);
        mDecorView.setIsRootNamespace(true);
        //設置elevation
        // The background owner should be elevated so that it casts a shadow.
        mBackgroundView.setElevation(mElevation);

        // We may wrap that in another view, so we'll need to manually specify
        // the surface insets.
        p.setSurfaceInsets(mBackgroundView, true /*manual*/, true /*preservePrevious*/);

        mPopupViewInitialLayoutDirectionInherited =
                (mContentView.getRawLayoutDirection() == View.LAYOUT_DIRECTION_INHERIT);
    }

這個方法可以說是popupwindow的最核心的方法了,首先我們可以看到,對mBackgroud變量進行了判空,如果設置了backgroud,則執行createBackgroundView方法。

private PopupBackgroundView createBackgroundView(View contentView) {
        final ViewGroup.LayoutParams layoutParams = mContentView.getLayoutParams();
        final int height;
        if (layoutParams != null && layoutParams.height == WRAP_CONTENT) {
            height = WRAP_CONTENT;
        } else {
            height = MATCH_PARENT;
        }

        final PopupBackgroundView backgroundView = new PopupBackgroundView(mContext);
        final PopupBackgroundView.LayoutParams listParams = new PopupBackgroundView.LayoutParams(
                MATCH_PARENT, height);
        backgroundView.addView(contentView, listParams);

        return backgroundView;
    }

這里可以看到,構建了一個寬高相同的布局參數,并且創建了一個PopupBackgroundView,利用addView方法,將我們的ContentView包裹了起來。

private class PopupBackgroundView extends FrameLayout {
        public PopupBackgroundView(Context context) {
            super(context);
        }

        @Override
        protected int[] onCreateDrawableState(int extraSpace) {
            if (mAboveAnchor) {
                final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
                View.mergeDrawableStates(drawableState, ABOVE_ANCHOR_STATE_SET);
                return drawableState;
            } else {
                return super.onCreateDrawableState(extraSpace);
            }
        }
    }

這里的PopupBackgroundView其實就是一個FrameLayout,單純的只是為了設置Backgroud
接下來執行createDecorView方法。

private PopupDecorView createDecorView(View contentView) {
        final ViewGroup.LayoutParams layoutParams = mContentView.getLayoutParams();
        final int height;
        if (layoutParams != null && layoutParams.height == WRAP_CONTENT) {
            height = WRAP_CONTENT;
        } else {
            height = MATCH_PARENT;
        }

        final PopupDecorView decorView = new PopupDecorView(mContext);
        decorView.addView(contentView, MATCH_PARENT, height);
        decorView.setClipChildren(false);
        decorView.setClipToPadding(false);

        return decorView;
    }

可以看到和剛才大同小異,哪這回為什么又要包裹一層呢?這里就要看一下PopupDecorView

private class PopupDecorView extends FrameLayout {
        /** Runnable used to clean up listeners after exit transition. */
        private Runnable mCleanupAfterExit;

        public PopupDecorView(Context context) {
            super(context);
        }

        @Override
        public boolean dispatchKeyEvent(KeyEvent event) {
                //對返回鍵做了特殊處理
            if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
                if (getKeyDispatcherState() == null) {
                    return super.dispatchKeyEvent(event);
                }

                if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) {
                    final KeyEvent.DispatcherState state = getKeyDispatcherState();
                    if (state != null) {
                        state.startTracking(event, this);
                    }
                    return true;
                } else if (event.getAction() == KeyEvent.ACTION_UP) {
                    final KeyEvent.DispatcherState state = getKeyDispatcherState();
                    if (state != null && state.isTracking(event) && !event.isCanceled()) {
                        dismiss();
                        return true;
                    }
                }
                return super.dispatchKeyEvent(event);
            } else {
                return super.dispatchKeyEvent(event);
            }
        }

        @Override
        public boolean dispatchTouchEvent(MotionEvent ev) {
            if (mTouchInterceptor != null && mTouchInterceptor.onTouch(this, ev)) {
                return true;
            }
            return super.dispatchTouchEvent(ev);
        }

        @Override
        public boolean onTouchEvent(MotionEvent event) {
            final int x = (int) event.getX();
            final int y = (int) event.getY();

            if ((event.getAction() == MotionEvent.ACTION_DOWN)
                    && ((x < 0) || (x >= getWidth()) || (y < 0) || (y >= getHeight()))) {
                //觸摸位置在外部,則直接dismiss()
                dismiss();
                return true;
            } else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
                dismiss();
                return true;
            } else {
                return super.onTouchEvent(event);
            }
        }
...
} 

這里就內容很多了,首先這個還是一個繼承了FrameLayout的布局,唯一不同的是,這里重寫了兩個關鍵方法dispatchKeyEventonTouchEvent,所以我們應該知道這里對鍵盤事件和觸摸事件做了特殊處理,當是返回鍵時或者觸摸位置在View的外部的時候則調用dismiss()方法。
這也就是為什么Popupwindow點擊外部可以消失的原因,也就是觸摸事件處理
這里還有一個地方值得我們注意

@Override
        public boolean dispatchTouchEvent(MotionEvent ev) {
            if (mTouchInterceptor != null && mTouchInterceptor.onTouch(this, ev)) {
                return true;
            }
            return super.dispatchTouchEvent(ev);
        }

可以看到這里還重寫了dispatchTouchEvent方法,熟悉Android事件分發流程的應該清楚,這里是事件分發的頂層,這里多出了一個mTouchInterceptor這個概念,其實就是一個攔截器,也就是說,對于PopupWindow,我們是可以自定義事件的處理的。
做完這所有的準備后,就是最后一個方法了。

private void invokePopup(WindowManager.LayoutParams p) {
        if (mContext != null) {
            p.packageName = mContext.getPackageName();
        }

        final PopupDecorView decorView = mDecorView;
        decorView.setFitsSystemWindows(mLayoutInsetDecor);

        setLayoutDirectionFromAnchor();
        //通過WindowManger加入View
        mWindowManager.addView(decorView, p);

        if (mEnterTransition != null) {
            decorView.requestEnterTransition(mEnterTransition);
        }
    }

終于看到了最核心的顯示方法,我們可以確定PopupWindow是通過WindowMangeraddView方法加入的。可以發現,其實PopupWindow并沒有重新創建新的Window,而是在當前Window上,利用WindowManger.addView加入的。,這可以說就是PopupWindow的顯示原理。

PopupWindow關于BackgroundDrawable的版本差異導致的問題

最開始學習PopupWindow的使用方法的時候,我們經常會看到這樣的一個注釋。

// 如果不設置PopupWindow的背景,就會出現一個問題:無論是點擊外部區域還是Back鍵都無法dismiss彈框
popupWindow.setBackgroundDrawable(new ColorDrawable());

通過上面的源碼分析,我們本沒有發現BackgroundDrawable會有這么大的影響,只是單純的印象一個包裝View的背景,這里就要說一下PopupWindow的版本差異了,本篇博客是基于android-28,通過源碼我們能知道backgrounddrawable不會有這樣的影響。但是我們來看一下Android4.2.2的源碼

private void preparePopup(WindowManager.LayoutParams p) {
        if (mContentView == null || mContext == null || mWindowManager == null) {
            throw new IllegalStateException("You must specify a valid content view by "
                    + "calling setContentView() before attempting to show the popup.");
        }

        if (mBackground != null) {
            final ViewGroup.LayoutParams layoutParams = mContentView.getLayoutParams();
            int height = ViewGroup.LayoutParams.MATCH_PARENT;
            if (layoutParams != null &&
                    layoutParams.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
                height = ViewGroup.LayoutParams.WRAP_CONTENT;
            }

            // when a background is available, we embed the content view
            // within another view that owns the background drawable
            PopupViewContainer popupViewContainer = new PopupViewContainer(mContext);
            PopupViewContainer.LayoutParams listParams = new PopupViewContainer.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT, height
            );
            popupViewContainer.setBackgroundDrawable(mBackground);
            popupViewContainer.addView(mContentView, listParams);

            mPopupView = popupViewContainer;
        } else {
            mPopupView = mContentView;
        }
        mPopupViewInitialLayoutDirectionInherited =
                (mPopupView.getRawLayoutDirection() == View.LAYOUT_DIRECTION_INHERIT);
        mPopupWidth = p.width;
        mPopupHeight = p.height;
    }

可以看到這里在preparePopup方法里,就有了不同,這里如果設置了mBackground就會使用PopupViewContainer保存。

private class PopupViewContainer extends FrameLayout {
1542        private static final String TAG = "PopupWindow.PopupViewContainer";
1543
1544        public PopupViewContainer(Context context) {
1545            super(context);
1546        }
1547
1548        @Override
1549        protected int[] onCreateDrawableState(int extraSpace) {
1550            if (mAboveAnchor) {
1551                // 1 more needed for the above anchor state
1552                final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
1553                View.mergeDrawableStates(drawableState, ABOVE_ANCHOR_STATE_SET);
1554                return drawableState;
1555            } else {
1556                return super.onCreateDrawableState(extraSpace);
1557            }
1558        }
1559
1560        @Override
1561        public boolean dispatchKeyEvent(KeyEvent event) {
1562            if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
1563                if (getKeyDispatcherState() == null) {
1564                    return super.dispatchKeyEvent(event);
1565                }
1566
1567                if (event.getAction() == KeyEvent.ACTION_DOWN
1568                        && event.getRepeatCount() == 0) {
1569                    KeyEvent.DispatcherState state = getKeyDispatcherState();
1570                    if (state != null) {
1571                        state.startTracking(event, this);
1572                    }
1573                    return true;
1574                } else if (event.getAction() == KeyEvent.ACTION_UP) {
1575                    KeyEvent.DispatcherState state = getKeyDispatcherState();
1576                    if (state != null && state.isTracking(event) && !event.isCanceled()) {
1577                        dismiss();
1578                        return true;
1579                    }
1580                }
1581                return super.dispatchKeyEvent(event);
1582            } else {
1583                return super.dispatchKeyEvent(event);
1584            }
1585        }
1586
1587        @Override
1588        public boolean dispatchTouchEvent(MotionEvent ev) {
1589            if (mTouchInterceptor != null && mTouchInterceptor.onTouch(this, ev)) {
1590                return true;
1591            }
1592            return super.dispatchTouchEvent(ev);
1593        }
1594
1595        @Override
1596        public boolean onTouchEvent(MotionEvent event) {
1597            final int x = (int) event.getX();
1598            final int y = (int) event.getY();
1599
1600            if ((event.getAction() == MotionEvent.ACTION_DOWN)
1601                    && ((x < 0) || (x >= getWidth()) || (y < 0) || (y >= getHeight()))) {
1602                dismiss();
1603                return true;
1604            } else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
1605                dismiss();
1606                return true;
1607            } else {
1608                return super.onTouchEvent(event);
1609            }
1610        }
1611
1612        @Override
1613        public void sendAccessibilityEvent(int eventType) {
1614            // clinets are interested in the content not the container, make it event source
1615            if (mContentView != null) {
1616                mContentView.sendAccessibilityEvent(eventType);
1617            } else {
1618                super.sendAccessibilityEvent(eventType);
1619            }
1620        }
1621    }
1622

可以看到,這里就直接處理的鍵盤事件和觸摸事件,那么就意味著如果我們沒有設置Background那么在低版本的情況下將會出現無法點擊外部消失這個功能,雖然后面的修復了這個問題,但是Google也留了一個很大的坑啊,而且為了包裝Background在展示上的一致性,在高版本無奈只能選擇使用兩次包裹來實現,也是費盡心思了。。。

總結

本篇博客主要分析了PopupWindow的實現原理,總的來看,PopupWindow主要是以下幾個步驟:

  1. 設置ContentView
  2. 利用自定義View包裹我們的ContentView,自定義View重寫了鍵盤事件和觸摸事件分發,實現了點擊外部消失
  3. 最終利用WindowManger的addView加入布局,并沒有創建新的Window
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容