Are you still upset about PopupWindow


Ps: The preview is at the end of the article

Actually wanted to write this article for a long time, however, has been always feel BasePopup short of their expectations, so there is no recommended how to spread, and therefore has not to write articles, until recently be reconstructed version 2.0, and perfected the wiki after the API documentation, just a little too happy, so they set out to write this article.

Warehouse address: github.com/razerdp/Bas…

I care more about your issue~ than star.


The status quo

If you’ve had a lot of fights with product managers and designers on Android, you know one thing: there’s no interaction that can’t be solved with pop-ups, and if there are, just play one more.

Of course, every interaction design has to think about how to squeeze out the limited screen space while maintaining an elegant interface.

At this point, they often choose a magic tool: popover.

Whether it’s from the bottom or from the middle, or up and down and right and left, or even with animation, darkening, blurring, etc., there are more and more patterns on the pop-up window, and it is often us programmers who cry.

In Android, there are quite a few things to choose from to handle popovers:

  • Dialog
  • BottomSheetDialog
  • DialogFragment
  • PopupWindow
  • WindowManager will simply deface into a View
  • Dialog-style Activity
  • Wait, wait, wait….

Most of the time, we choose Dialog over PopupWindow for a simple reason… PopupWindow has a lot of holes!!

Pros and cons of PopupWindow

Compared to Dialog, PopupWindow’s position is more arbitrary and can be displayed in any position, while Dialog is relatively fixed. Second, PopupWindow’s background darkening effect makes it easy to customize the background without complex hacking technology.

There are many disadvantages, which is why people prefer Dialog. Here are a few that I think are most significant:

  • Creating dialogs is complicated, and it’s annoying to have to write templated initializations every time compared to Dialog
  • Clicking on events hurts, either doesn’t respond to Backpress, or clicking on the outside doesn’t disappear (background issues between system versions)
  • PopupWindow has been quietly changed with each release, and even worse, new issues have been introduced after fixing old bugs (e.g., 7.0 height match_parent displays differently than before).
  • PopupWindow cannot be pasted (this is an inherent problem because pasting is also a PopupWindow, and views inside PopupWindow cannot get windowToken)
  • Cumbersome location

To that end, the BasePopup library was born.

BasePopup solution

From release 1.0 to release 2.1.1 (ready for release 2.1.2), the number of pothole walks and reads of the PopupWindow source code to develop BasePopup is quite a bit. Of course, there are still some pothole holes left, but BasePopup is ready for most situations.

Although this article is mainly to recommend BasePopup, it is more to share my solution Idea with everyone. I have been maintaining this library by myself, and few people have communicated with me about the key points of implementation. I would like to share this article with you and hope to get more people’s suggestions or criticisms.

Create complex

First let’s look at the normal PopupWindow:

// the following three sentences can be combined into one sentence in the constructor, but to prevent the content is too long, we write them separately
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);
Copy the code

PopupWindow has more than five constructors, and even with the IDE’s autoprompt, it can be a pain to deal with a lot of constructors.

In BasePopup, we simply inherit the BasePopupWindow and override the onCreateContentView method to return your contentView. Externally, it’s just two or even one line of code.

new DemoPopup(getContext()).showPopupWindow();
Copy the code

You might say, well, it hurts even more, because FOR a PopupWindow, I have to write an extra class.

The problem, like MVP, is that more classes have to be created for better structure…

BasePopup was written as an abstract class to allow developers to build functionality into PopupWindow, rather than to solve PopupWindow’s various headaches.

Of course, in order to satisfy some simple PopupWindow implementations without creating a new class, we also provide lazy methods to support chained use:

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();
Copy the code

BasePopup is an abstract class that leaves implementation to subclasses (i.e., developers) and also provides interceptors for developers to interfere with internal logic and maximize open customization.

There may be better methods or design patterns, such as adapters, that I won’t go into here.

You care more about other implementations than encapsulation.


The event consumer

PopupWindow events have always been a pain in the neck. Prior to 6.0, it was impossible to respond to external click events without setting background, but this was fixed after 6.0.

What causes this to happen is the implementation mechanism inside PopupWindow.

When we set up a contentView for PopupWindow, the contentView is wrapped in a DecorView inside the PopupWindow, and the response to the event is distributed by the DecorView.

PreparePopup () before 6.0, preparePopup #preparePopup() source:

    private void preparePopup(WindowManager.LayoutParams p) {
		// Ignore some code

        if(mBackground ! =null) {
            // Ignore part of the code to wrap the contentView around background when it is not empty
            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;
        }
			// Ignore the following code
    }
Copy the code

From 6.0, preparePopup source code is as follows:

    private void preparePopup(WindowManager.LayoutParams p) {
		// Ignore some code
        if(mBackground ! =null) {
            mBackgroundView = createBackgroundView(mContentView);
            mBackgroundView.setBackground(mBackground);
        } else {
            mBackgroundView = mContentView;
        }
		// Wrap the contentView in the DecorView
        mDecorView = createDecorView(mBackgroundView);
        mDecorView.setIsRootNamespace(true);

        // Ignore the following code
    }
Copy the code

For PopupWindow events, they are handled in the dispatchKeyEvent and onTouchEvent methods of the internal DecorView, so the source code is not posted here.

Since dispatchKeyEvent cannot be intercepted by setting event listener and PopupWindow DecorView cannot be retrieved, it seems that event distribution has reached a dead end. However, by perusing the source code, we have found a way to break through: WindowManager.

proxy WindowManager

PopupWindow does not create a new Window, it adds a new View via WindowManager whose Type is TYPE_APPLICATION_PANEL, so PopupWindow requires a windowToken for attachment.

In PopupWindow, our contentView is wrapped in the DecorView, which is added to the interface through the WindowManager.

Since the event distribution is in the DecorView and there is no listener to intercept it, we need to wrap the DecorView with another layer of our custom controls and add it to the Window, so that the DecorView becomes our subclass. We have absolute control over event distribution (even measure/layout), which BasePopup does.

However, the above steps are preconditioned on how to proxy Windows Manager. (Equivalent to looking for hook points)

In PopupWindow, we can read the source code to know that the WindowManager in PopupWindow is initialized in two places:

  • In the construction method
  • setContentView()

So, we also start with those two places, inheriting PopupWindow and overwriting the above two methods, in which we get WindowManager by reflection and wrap it in our WindowManagerProxy, We then set our WindowManagerProxy to PopupWindow, and the proxy will succeed.

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

    private BasePopupHelper mHelper;
    private WindowManagerProxy mWindowManagerProxy;

    // All constructors call init(). The other constructors are ignored here

    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);
    }



    /** * Try to broker 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 after the proxy, need to use black technology
    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 before the proxy, ordinary reflection can be
    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, "Attempted to broker WindowManager successfully");
        } catch (NoSuchFieldException e) {
            if (Build.VERSION.SDK_INT >= 27) {
                troToProxyWindowManagerMethodOverP(popupWindow);
            } else{ e.printStackTrace(); }}catch(Exception e) { e.printStackTrace(); }}}Copy the code

Speaking of reflection, there must be some people here who think there will be performance problems. To be honest, I also had this concern at the beginning, but in fact, since ART, the performance impact of reflection has been reduced a lot, and we do not have frequent reflection here, so I think it can be ignored at this point.

Looking beyond broadening, BasePopup uses an UnSafe platform to bypass Api calls. Even though broadening is UnSafe, WindowManager is not whitelisted, so BasePopup uses an UnSafe platform to bypass Api calls. This library takes the last one, the specific here will not elaborate.

System version differences and other issues

Position control

The location problems caused by system versioning are very troublesome. I adapted api24 before, API24, and api24 after a class, and found that I was writing more and more, so I came up with a bold idea:

The PopupWindow location is up to us

Because of the agent above, we have absolute control over the DecorView of PopupWindow, so the PopupWindow display problem due to the system version is easily resolved.

For the PopupWindow location, because the DecorView is a child of our custom control, the approach in BasePopup is to completely override onLayout().

Our custom controls cover the entire screen, so we apply a layout to the DecorView that visually displays the PopupWindow in the specified position (the background is transparent, and the contentView is user-specified XML, usually colored). But PopupWindow actually covers the entire screen.

(Of course, for ordinary use, PopupWindow does not cover the entire screen.)

Here is part of the layout code:

    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);
                // When not in contact with anchorView, gravity means the position in the overall view
                // If contacted with anchorView, gravity means the location centered on the 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;

                // For clipToScreen and autoLocated cases, this is ignored due to space constraints
                }
                child.layout(left, top, right, bottom);
                if(delayLayoutMask) { mMaskLayout.handleAlignBackground(left, top, right, bottom); }}}}Copy the code

For layout, we only need to distinguish whether PopupWindow is associated with anchorView or not, and then calculate the position based on Gravity and Offset.

These operations are easy for those of you who often customize controls.

For normal PopupWindow usage, where PopupWindow does not cover the entire screen, BasePopup calculates offset as usual.

    private void onCalculateOffsetAdjust(View anchorView, Point offset) {
        if(anchorView ! =null) {
            // Since the showAsDropDown system has already positioned us below the view, we only need to make a slight offset here

            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:
                    // The system default is below.
                    break;
                case Gravity.CENTER_VERTICAL:
                    offset.y += -((getHeight() + mHelper.getAnchorHeight()) >> 1);
                    break;
                default:
                    break; }}}Copy the code

Because we control the position, not only is the position calculated uniformly across all versions, but more importantly, PopupWindow Gravity is fully used, no longer having to calculate the offset of the heart plug.

For example, let’s say we want to display it to the right of a view and align ourselves vertically with it.

In your system’s PopupWindow, you might write:


// Ignore the creation method
popup.showAsDropDown(v,v.getWidth(),-(v.getHeight()+popup.getHeight())>>1)
Copy the code

The code above is relatively simple, popup is displayed below anchorView by default, and the offset needs to be calculated so that popup can be offset to the right of the view, but one concern is that popup can’t get the correct contentView width until it is displayed.

In BasePopup, all you have to say is:


// Ignore the creation method
popup.setPopupGravity(Gravity.RIGHT|Gravity.CENTER_VERTICAL);
popup.showPopupWindow(anchorView);
Copy the code

In BasePopup, we know the width and height of the contentView, so we can calculate the correct position of the Popup by Gravity.

Gravity Demo:

The background of fuzzy

We can also add a background to the custom ViewGroup by default. In BasePopup, the background is added with an ImageView and a View, which handle blurring and background colors, respectively.

RenderScript is used for background blur, and fastBlur is used for unsupported blur. Since blur is basically the same, I won’t post the code here.

Other problems

So far, BasePopup works for most PopupWindow uses, but there are still some shortcomings, such as the lack of PopupWindow update() support, because most of the time PopupWindow is used for display, and it is almost always displayed once and then eliminated.

However, the need for PopupWindow to follow a View and update its position is not ruled out, so in the following maintenance, this problem will be included in the future work.

Finally, I would like to thank my friends who have raised the issue. I have carefully read every issue of yours and will clear it when I have time.

Finally finally, HOPE this article can see this article you some help ~

thanks

Warehouse address: github.com/razerdp/Bas…


18/12/19: Candy update to 2.1.3-alpha, support update~ thanks for your support

Preview:

AnchorView binding Eject in different directions
Any position display Refer to anchorView update
Pop from below and blur the background Moments comment popover