现在一个普通activity页希望对话框弹出来之后, 空白区域仍然能进行滑动点击等操作,也就是希望能够透传给下面的activity, 同时原有的在对话框视图上的各种点击和滑动操作也不应该受到影响. 这个需求听起来多余且棘手, 对话框弹出的目的就是为了强化提醒和屏蔽操作,现在竟然要去除那就失去了使用对话框的意义了, 然而iOS竟然可以做到!所以不得不硬着头皮看源码实现一下.

众所周知安卓中的Dialog是在另外一个Window实例中添加的视图,比Activity所在的Widnow层级要高. 如果仅仅是为了达到效果,如上所说其实就根本不应该用Dialog! 能够想到的控件当然是PopupWindow了,但改成PopupWindow之后它的层级就放在了Activity所在的Window了, 层级一低会破坏原有程序实现的很多case,而且可能出现测试覆盖不到的情况增加线上风险, 所以很多实现的麻烦并不是实现本身,而且是涉及太多的程序上下文.

只能针对Dialog进行修改了,这篇帖子给我们一个启发(虽然帖子本身没人回答), 那就是利用Activity.dispatchTouchEvent! 把没有消费的事件整个的交给activity实例去处理,这样即能透传事件也不影响原有对话框视图的各种操作! 那么Touch事件又从何而来? 这就得了解Dialog的实现机制了,先看Dialog创建时的源码:

mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);

final Window w = new PhoneWindow(mContext);
mWindow = w;
w.setCallback(this);
w.setOnWindowDismissedCallback(this);
w.setOnWindowSwipeDismissedCallback(() -> {
    if (mCancelable) {
        cancel();
    }
});

Dialog创建了一个Window的实例, 最关键的是w.setCallback(this), window的所有回调都传给了dialog对象,其中就有public boolean dispatchTouchEvent(MotionEvent event);, 同时各种dialog的子类AppCompatDialog, AlertDialog都有可覆盖的方法: public boolean onTouchEvent(@NonNull MotionEvent event), 这就h了, 只要在dialog的子类持有activity实例, 再把没有消费的事件直接传送给activity不就完事了! 一个小的delegate而已, 于是有:

class YourDialog(context: Context) : AlertDialog(context, resolveDialogTheme(context, 0)) {
    private val activity = if (context is Activity) context else null

    companion object {
        // Copy from AlertDialog.resolveDialogTheme
        fun resolveDialogTheme(context: Context, @StyleRes resid: Int): Int {
            // Check to see if this resourceId has a valid package ID.
            return if (resid ushr 24 and 0x000000ff >= 0x00000001) {   // start of real resource IDs.
                resid
            } else {
                val outValue = TypedValue()
                context.theme.resolveAttribute(android.R.attr.alertDialogTheme, outValue, true)
                outValue.resourceId
            }
        }
    }

    override fun onTouchEvent(ev: MotionEvent): Boolean {
        return super.onTouchEvent(ev) || passThrough(ev)
    }

    private fun passThrough(ev: MotionEvent): Boolean {
        return activity?.dispatchTouchEvent(ev) ?: false
    }
}
  • resolveDialogTheme这个方法是为了获取Dialog对应主题,因为声明成package access只能copy过来;
  • 另一个需要注意的问题是activity的实例不能从dialog的context获取, 它的实际类型是ContextThemeWrapper;
  • 第三透传给activity的是dialog没有消费的事件,所以onTouchEvent返回false才调用passThrough.
  • 最后 也是特别需要注意的 需要设置对话框的2个方法:

    setCanceledOnTouchOutside(false)
    setCancelable(false)

    如果没有这2行会发生什么? 实际运行一下就会发现最后2个问题其实是一个问题

这个实现简单安全甚至还带着几分优雅~

---------- 0628

果然还是有问题!

发现滚动是好的, 但是点击操作有偏移! y坐标总是偏移了一个statusbarHeight的距离,传到下层的Activity就是点击的位置向上偏了, 虽然可以获取到statusbar高度并且强行修改MotionEvent(x, y)信息,但底层的activity也有可能是是FULLSCREEN的,也许会引入问题, 最终发现我们实际需要的y就是MotionEventrawY, 于是再创建一个新的MotionEvent对象传入就ok, 于是有:

     private fun passThrough(ev: MotionEvent): Boolean {
-        return activity?.dispatchTouchEvent(ev) ?: false
+        val e = MotionEvent.obtain(ev.downTime, ev.eventTime, ev.action,
+            ev.rawX, ev.rawY,
+            ev.pressure, ev.size, ev.metaState, ev.xPrecision, ev.yPrecision, ev.deviceId, ev.edgeFlags)
+        return activity?.dispatchTouchEvent(e) ?: false
     }

原始的位置信息(ev.rawX, ev.rawY)(而不是(ev.x, ev.y + statusBarHeight))就是我们需要透传给activity的位置信息, 让activity自己决定要如何处理触摸,这样避免了位置信息的强行修改.

另外一个问题: 当点击下层activity视图上的输入框时, 输入法无法弹起!

这个有点棘手, 不过一般输入法不弹是因为没有获取到焦点(没有其它错误的情况下),那有让dialog去除焦点或者禁止获取焦点的方法么? 非常幸运的是我们有一个针对Window的flag: FLAG_NOT_FOCUSABLE, 于是在创建对话框后获取到它的window并设置:

window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)

如此,输入法果然按照预期弹起了~!


林鹿
21 声望5 粉丝