Android悬浮窗实现点击空白区域监听

3,758 阅读2分钟

介绍

设置了一个悬浮窗,点击展开后,期望点击或者滑动空白处自动折叠回原本的位置。

效果图

最终效果.gif

如何实现

1、瞄瞄源码

源码是最好的老师,看看PopupWindow是如何实现的:

设置PopupWindow最后需要调用showAsDropDown展示,看看它的WindowManager.LayoutParams是怎么设置的。

public void showAsDropDown(View anchor, int xoff, int yoff, int gravity) {
    ... 省略
    final WindowManager.LayoutParams p =
            createPopupLayoutParams(anchor.getApplicationWindowToken());
    ... 省略
}

protected final WindowManager.LayoutParams createPopupLayoutParams(IBinder token) {
    ... 省略
    p.flags = computeFlags(p.flags);
    ... 省略
}

private int computeFlags(int curFlags) {
    curFlags &= ~(
            WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES |
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE |
            WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE |
            WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH |
            WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS |
            WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM |
            WindowManager.LayoutParams.FLAG_SPLIT_TOUCH);
    ... 省略
    if (mOutsideTouchable) {
        // 找到你了🥳
        curFlags |= WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH;
    }
    ... 省略
    return curFlags;
}

这个WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH点进去看看。

翻译一下:如果你已经设置了FLAG_NOT_TOUCH_MODAL,那么你可以设置FLAG_WATCH_OUTSIDE_TOUCH这个flag。这样一个点击事件如果发生在你的窗口之外的范围,你就会接收到一个特殊的MotionEvent.ACTION_OUTSIDE。注意,你只会接收到点击事件的第一下,而之后的DOWN/MOVE/UP等手势全都不会接收到。

/** Window flag: if you have set {@link #FLAG_NOT_TOUCH_MODAL}, you
 * can set this flag to receive a single special MotionEvent with
 * the action
 * {@link MotionEvent#ACTION_OUTSIDE MotionEvent.ACTION_OUTSIDE} for
 * touches that occur outside of your window.  Note that you will not
 * receive the full down/move/up gesture, only the location of the
 * first down as an ACTION_OUTSIDE.
 */
public static final int FLAG_WATCH_OUTSIDE_TOUCH = 0x00040000;

再搜索下MotionEvent.ACTION_OUTSIDE

@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();
        return true;
    } else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
        dismiss(); // 666🤙🏻🤙🏻🤙🏻,在这里监听ACTION_OUTSIDE然后dismiss()
        return true;
    } else {
        return super.onTouchEvent(event);
    }
}

2、使用WindowManager向窗口添加自定义MusicPlayerAndroidView

设置WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCHwidth = WRAP_CONTENT

val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
val layoutParam = WindowManager.LayoutParams().apply {
    type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
    } else {
        WindowManager.LayoutParams.TYPE_PHONE
    }
    format = PixelFormat.RGBA_8888
    flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
            WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
    width = WindowManager.LayoutParams.WRAP_CONTENT // 注意此处用WRAP_CONTENT
    height = WindowManager.LayoutParams.WRAP_CONTENT
    gravity = Gravity.START or Gravity.TOP
}
val floatRootView = MusicPlayerAndroidView(this) // 自定义的view
windowManager.addView(floatRootView, layoutParam)

3、重写dispatchTouchEvent

override fun dispatchTouchEvent(event: MotionEvent): Boolean {
    when (event.action and MotionEvent.ACTION_MASK) {
        MotionEvent.ACTION_OUTSIDE -> {
            if (!isFold) // 如果不是折叠状态,调用折叠动画
                fold()
        }
    }
    return super.dispatchTouchEvent(event)
}

4、动画

动画需要动态改变view的宽度,不能直接改变windowParams.width这样右边的圆角不好处理,并且不断调用updateViewLayout()做动画很卡顿。调用更多次的onMeasure与onLayout

简单设置动画后,发现效果很卡顿。这是因为我们设置了windowParams.width = WRAP_CONTENT,view的宽度不断变化。

private val minWidth = 50f.dp.toInt()
private val maxWidth = 150f.dp.toInt()

private val extendAnimator by lazy {
    ValueAnimator.ofInt(minWidth, maxWidth).apply {
        addUpdateListener {
            mBinding.root.layoutParams = mBinding.root.layoutParams.apply {
                width = it.animatedValue as Int
            }
        }
    }
}

private val foldAnimator by lazy {
    ValueAnimator.ofInt(maxWidth, minWidth).apply {
        addUpdateListener {
            val value = it.animatedValue as Int
            mBinding.root.layoutParams = mBinding.root.layoutParams.apply {
                width = value
            }
        }
    }
}

卡顿.gif

但是如果一开始就把windowParams的宽度设置为最大宽度呢?windowParams.width = 150f.dp.toInt()悬浮窗无法拖动到最右边。

width.gif

所以我们需要在展开的时候,设置windowParams的宽度为动画最大宽度; 在折叠的时候,设置windowParams的宽度为动画最小宽度

windowManager.updateViewLayout(this, windowParams.apply {
    width = maxWidth
})
extendAnimator.start()

private val foldAnimator by lazy {
    ValueAnimator.ofInt(maxWidth, minWidth).apply {
        addUpdateListener {
            val value = it.animatedValue as Int
            mBinding.root.layoutParams = mBinding.root.layoutParams.apply {
                width = value
            }
            // 新加下面代码
            if (value == minWidth) {
                windowManager.updateViewLayout(this@MusicPlayerAndroidView, windowParams.apply {
                    width = minWidth
                })
            }
        }
    }
}

🎉🎉🎉