简述:

PopupWindow 是 Android 提供的一种浮层窗口,可以用于展示弹出的内容,比如菜单、提示、广告、输入框等,不会影响 Activity 本身的布局结构。

常见用途:

  • 下拉菜单(如 Spinner 的下拉项)
  • 广告半屏浮层或全屏浮层
  • 输入框提示(AutoCompleteTextView)
  • 自定义弹出面板(例如日期选择器)

PopupWindow 核心特性

特性说明
独立于 Activity悬浮在界面顶层,不占用布局空间
可自定义内容内容可以是任意 View(如 LinearLayoutListView
灵活控制位置可指定相对于某个 View 或屏幕坐标显示
可设置动画支持进入/退出动画
模态/非模态可设置是否拦截背景点击事件
特性PopupWindowDialog
UI 结构不影响 Activity 层级影响 UI 层级,阻塞事件传递
可定制性灵活,可以任意位置显示居中弹出,结构固定
背景遮罩默认无,需要手动设置默认带有遮罩(模态)
生命周期不受 Activity 生命周期控制受控于 Activity 生命周期
/**
 * 展示 PopupWindow
 */
private fun showPopupWindow(anchorView: View) {
 
    // 对已经展示的 PopupWindow 进行关闭(避免重复创建)
    mPopupWindow?.takeIf { it.isShowing }?.dismiss()
 
    // 通过 LayoutInflater 加载自定义弹窗布局
    val popupView = LayoutInflater.from(this).inflate(R.layout.popup_content, null)
 
    // 设置关闭按钮
    val ivClose = popupView.findViewById<ImageView>(R.id.iv_close)
    ivClose.setOnClickListener {
        if (mPopupWindow != null && mPopupWindow!!.isShowing) {
            mPopupWindow!!.dismiss()
        }
    }
 
    // 设置弹窗内的文本内容
    val tvMessage: TextView = popupView.findViewById<TextView>(R.id.tv_popup_message)
    tvMessage.text = "这是一个 PopupWindow 示例"
 
    // 创建 PopupWindow 实例,宽高设为 wrap_content,第三个参数为 focusable:允许获得焦点(影响返回键关闭)
    mPopupWindow = PopupWindow(
        popupView,
        ViewGroup.LayoutParams.MATCH_PARENT,
        ViewGroup.LayoutParams.WRAP_CONTENT,
        true
    )
 
    // 设置弹窗背景为透明 Drawable:
    // 这是触发外部点击关闭的前提条件(无背景则点击无效)
    mPopupWindow?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT));
 
    // 设置可点击弹窗外部区域以关闭弹窗
    mPopupWindow?.isOutsideTouchable = true;
 
    // 设置可以响应返回键关闭
    mPopupWindow?.isFocusable = true;
 
    // 设置弹窗的进出动画(可自定义样式)
    mPopupWindow?.animationStyle = android.R.style.Animation_Dialog;
 
    // 显示 PopupWindow,位置在 anchorView 的下方,y 方向偏移 20 像素
    mPopupWindow?.showAsDropDown(anchorView, 0, 20);
}

其中:

  • width, height可设为WRAP_CONTENT, MATCH_PARENT, 或具体数值;
  • focusabletrue表示 PopupWindow 可获取焦点,支持返回键关闭。

另一种显示方式:固定屏幕位置

mPopupWindow?.showAtLocation(
    anchorView,
    Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL, // 底部居中
    0,    // 水平偏移(0 表示完全居中)
    500   // 垂直偏移(从底部向上偏移 100px)
)

生命周期管理

避免重复创建,通常我们会使用成员变量来持有 popup 实例:

private var popupWindow: PopupWindow? = null

每次点击前判断:

// 对已经展示的 PopupWindow 进行关闭(避免重复创建)
mPopupWindow?.takeIf { it.isShowing }?.dismiss()

防止内存泄漏

PopupWindow 持有 View,如果它的 Context 是已销毁的 Activity,会导致泄漏。需要销毁。

override fun onDestroy() {
    popupWindow?.dismiss()
    super.onDestroy()
}

聚焦于实际开发中非常关键的内容:布局、位置、尺寸控制、动画与交互行为设计

显示位置控制

PopupWindow 常用的两个显示方法:

showAsDropDown(View anchor, int xoff, int yoff)

  • 将弹窗显示在锚点 View 的正下方;
  • xoff, yoff 是偏移量(相对 anchor 左上角)。
popupWindow.showAsDropDown(anchorView, 0, 10)

showAtLocation(View parent, int gravity, int x, int y)

  • 更自由,适合全屏遮罩弹窗;
  • gravity 控制显示方向,配合 (x, y) 指定绝对位置。
popupWindow.showAtLocation(parentView, Gravity.CENTER, 0, 0)

尺寸与边界控制

弹窗布局尺寸是常见 Bug 来源:

wrap_content 是相对的!

  • 如果你在 PopupWindow 设置中用了 WRAP_CONTENT,但弹窗内部布局宽高是 match_parent,会导致全屏;
  • 所以弹窗内部的根布局也要正确设置为 wrap_content 或加边框。
<LinearLayout
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:background="@drawable/popup_background"
  ... />

动画设置

可用系统默认,也可以自定义:

popupWindow.animationStyle = android.R.style.Animation_Dialog

自定义动画:

<!-- res/anim/popup_enter.xml -->
<set xmlns:android="http://schemas.android.com/apk/res/android"
     android:duration="300">
    <translate android:fromYDelta="100%" android:toYDelta="0%"/>
    <alpha android:fromAlpha="0.0" android:toAlpha="1.0"/>
</set>

然后在 styles.xml 配上:

<style name="PopupAnim">
    <item name="android:windowEnterAnimation">@anim/popup_enter</item>
    <item name="android:windowExitAnimation">@anim/popup_exit</item>
</style>

背景遮罩与点击关闭

设置背景**(必须要,否则点击外部无效):**

在 Android 中,PopupWindow 的点击外部关闭功能(isOutsideTouchable必须配合背景设置才能正常工作。如果没有设置背景,即使设置了 isOutsideTouchable = true,点击 PopupWindow 外部也不会触发关闭。

popupWindow.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
popupWindow.isOutsideTouchable = true
popupWindow.isFocusable = true

点击外部自动关闭:

  • 需要上述设置;
  • 如果用的是 showAtLocation,还可自己加遮罩半透明背景并监听点击关闭。

一个例子:全屏 PopupWindow

class DraggableWebPopup(private val context: Activity) : PopupWindow() {
 
    private val TAG = DraggableWebPopup::class.java.simpleName
 
    /** 内容区 */
    private lateinit var contentLayout: LinearLayout
    private lateinit var inputField: TextView
    private lateinit var webView: WebView
 
    /** 全屏高度 */
    private val maxHeight by lazy {
        val rect = Rect()
        context.window.decorView.getWindowVisibleDisplayFrame(rect)
        rect.height()
    }
 
    /** 半屏高度 */
    private val halfHeight: Int by lazy { maxHeight / 2 }
 
    /** 初始圆角 */
    private var currentRadius = 100f
 
    /** 圆角 drawable */
    private val roundedBg by lazy {
        GradientDrawable().apply {
            setColor(Color.WHITE)
            cornerRadii = floatArrayOf(
                currentRadius, currentRadius,
                currentRadius, currentRadius,
                0f, 0f,
                0f, 0f
            )
        }
    }
 
    /**
     * 展示 PopupWindow
     */
    fun show(url: String = "https://www.baidu.com") {
        val rootLayout = LayoutInflater.from(context).inflate(R.layout.draggable_popup_window, null)
        rootLayout.findViewById<FrameLayout>(R.id.popup_view).setOnClickListener { dismiss() }
 
        contentLayout = rootLayout.findViewById(R.id.container_content)
 
        // 初始化 WebView(如需)
        // webView = rootLayout.findViewById(R.id.webview)
        // webView.settings.javaScriptEnabled = true
        // webView.loadUrl(url)
 
        // 内容展示区初始为半屏大小
        contentLayout.layoutParams.height = halfHeight
        contentLayout.background = roundedBg
 
        // 设置 PopupWindow 本身
        contentView = rootLayout
        width = ViewGroup.LayoutParams.MATCH_PARENT
        height = ViewGroup.LayoutParams.MATCH_PARENT
        isFocusable = true
        isOutsideTouchable = true
        animationStyle = android.R.style.Animation_Dialog
        setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
 
        showAtLocation(context.window.decorView, Gravity.BOTTOM, 0, 0)
 
        setupDrag()
    }
 
    private fun setupDrag() {
 
        /** 上次按下时 Y 的坐标 */
        var lastY = 0f
 
        contentLayout.setOnTouchListener { v, event ->
            when (event.actionMasked) {
                MotionEvent.ACTION_DOWN -> lastY = event.rawY
 
                MotionEvent.ACTION_MOVE -> {
                    val dy = event.rawY - lastY
                    lastY = event.rawY
 
                    val newHeight = (v.height - dy).coerceIn(halfHeight.toFloat(), maxHeight.toFloat())
                    v.updateLayoutParams<ViewGroup.LayoutParams> { height = newHeight.toInt() }
 
                    val ratio = (newHeight - halfHeight) / (maxHeight - halfHeight).coerceAtLeast(1)
                    currentRadius = 100f * (1f - ratio.coerceIn(0f, 1f))
                    (v.background as GradientDrawable).cornerRadii = floatArrayOf(
                        currentRadius, currentRadius,
                        currentRadius, currentRadius,
                        0f, 0f, 0f, 0f
                    )
                }
 
                MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                    val currentHeight = v.height
                    val threshold = (halfHeight + maxHeight) / 2
                    val targetHeight = if (currentHeight > threshold) maxHeight else halfHeight
                    animateHeight(v, currentHeight, targetHeight)
                }
            }
            true
        }
    }
 
    private fun animateHeight(view: View, from: Int, to: Int) {
 
        // 计算本次动画圆角起止
        val radiusTo = if (to == maxHeight) 0f else 100f
        val animator = ValueAnimator.ofInt(from, to).apply {
            duration = 300
            interpolator = DecelerateInterpolator()
        }
 
        // 直角
        val radii = FloatArray(8)
        animator.addUpdateListener {
            val value = it.animatedValue as Int
            view.layoutParams.height = value
            view.requestLayout()
 
            val fraction = it.animatedFraction
            val radius = currentRadius + (radiusTo - currentRadius) * fraction
 
            // 只改前 4 个角
            radii.fill(radius, 0, 4)
            radii.fill(0f, 4, 8)
            (view.background as GradientDrawable).cornerRadii = radii
        }
 
        animator.start()
    }
}

Info

这里遇到几个小问题:

  1. 由于刷新的问题导致 popupWindow 频闪。解决方式:在 popupWindow 外面套一层,外层为透明。这样 popupWindow 在拖动的时候不会影响下层,就不会产生频闪的问题。

更新: 2025-08-02 21:20:53
原文: https://www.yuque.com/dongpozhouzi-mshe3/zhm85g/awcu8tx6616qfprp


相关笔记