亲,还在为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更新
从下方弹出并模糊背景 朋友圈评论弹窗
相关文章
相关标签/搜索