07.21 控件-对话框与菜单

2025/1/18 Android 开发基础

除了常规控件(07.01~07.19)和自定义 View(07.20),应用中还有一类特殊的界面元素——弹层:对话框、浮层、菜单和轻提示。它们不占据页面布局,而是在合适的时机"浮"在内容之上,用于确认操作、临时选择、快捷菜单和状态反馈。

先把弹层家族的全貌看清楚,再逐个展开:

弹层家族分类与选型示意图

# 一、AlertDialog 对话框

AlertDialog 是最常用的标准对话框,由 Material 风格的三段式构成:标题、内容区、按钮区。它不能直接实例化,通过 AlertDialog.Builder 构建。

# 1.1 基本对话框(标题 + 内容 + 按钮)

AlertDialog dialog = new AlertDialog.Builder(this)
        .setTitle("删除确认")
        .setMessage("确定要删除这条记录吗?删除后无法恢复。")
        .setPositiveButton("删除", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                // which是按钮标识(POSITIVE_BUTTON/NEGATIVE_BUTTON/NEUTRAL_BUTTON)
                deleteRecord();
            }
        })
        .setNegativeButton("取消", null)  // 传null:点击只关闭对话框
        .show();

按钮最多三个,语义约定:

  • setPositiveButton:肯定操作(确定/删除/同意),显示在最右
  • setNegativeButton:否定操作(取消/拒绝),显示在肯定键左侧
  • setNeutralButton:中性操作(忽略/稍后),显示在最左

# 1.2 列表 / 单选 / 多选对话框

// 1. 普通列表:点击任意项后自动关闭
new AlertDialog.Builder(this)
        .setTitle("选择颜色")
        .setItems(new String[]{"红色", "绿色", "蓝色"}, new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                Toast.makeText(MainActivity.this, "选中:" + which, Toast.LENGTH_SHORT).show();
            }
        })
        .show();

// 2. 单选列表:默认选中项,需配合确定按钮提交
new AlertDialog.Builder(this)
        .setTitle("选择性别")
        .setSingleChoiceItems(new String[]{"男", "女", "保密"}, 0,  // 0为默认选中索引
                new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                Log.i(TAG, "当前选中:" + which);
                // 注意:单选点击后默认不关闭,由确定按钮统一提交
            }
        })
        .setPositiveButton("确定", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                ListView list = ((AlertDialog) dialog).getListView();
                Log.i(TAG, "提交选中:" + list.getCheckedItemPosition());
            }
        })
        .show();

// 3. 多选列表:返回boolean[]记录各选项选中状态
new AlertDialog.Builder(this)
        .setTitle("选择兴趣爱好")
        .setMultiChoiceItems(new String[]{"运动", "阅读", "音乐", "旅行"},
                new boolean[]{true, false, false, true},  // 初始选中状态
                new DialogInterface.OnMultiChoiceClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which, boolean isChecked) {
                Log.i(TAG, which + " 选中状态:" + isChecked);
            }
        })
        .setPositiveButton("确定", null)
        .show();

# 1.3 自定义布局对话框

内容区可以换成自己的布局(如登录框、输入框):

// 1. 加载自定义布局
View view = LayoutInflater.from(this).inflate(R.layout.layout_login_dialog, null);
EditText etName = view.findViewById(R.id.et_name);
EditText etPwd = view.findViewById(R.id.et_password);

// 2. setView设置内容区(自定义时不要再调setMessage,二者互斥)
AlertDialog dialog = new AlertDialog.Builder(this)
        .setTitle("登录")
        .setView(view)
        .setPositiveButton("登录", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                login(etName.getText().toString(), etPwd.getText().toString());
            }
        })
        .setNegativeButton("取消", null)
        .show();

常用控制方法:

  • dialog.show():显示(Builder 链式最后一步,或单独调用)
  • dialog.dismiss():关闭对话框
  • dialog.setCancelable(false):禁止点空白处/返回键关闭(强提醒场景)
  • dialog.setOnCancelListener / setOnDismissListener:关闭时机回调

# 二、DialogFragment:对话框的推荐载体

直接持有 AlertDialog 引用有个经典坑:旋转屏幕(配置变更)时 Activity 重建,对话框消失。官方推荐用 DialogFragment 承载对话框——它本身是 Fragment,旋转后系统会自动恢复显示。

/**
* 确认对话框:用DialogFragment承载,旋转屏幕后依然存在.
*/
public class ConfirmDialogFragment extends DialogFragment {

    // 返回要显示的对话框
    @NonNull
    @Override
    public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
        String message = "";
        if (getArguments() != null) {
            message = getArguments().getString("message");
        }
        return new AlertDialog.Builder(requireContext())
                .setTitle("提示")
                .setMessage(message)
                .setPositiveButton("确定", new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        // 通过接口/ViewModel把结果传回宿主(避免直接持有Activity引用)
                    }
                })
                .setNegativeButton("取消", null)
                .create();
    }

    // 对外提供展示入口(Fragment间传参用Bundle,规范做法)
    public static ConfirmDialogFragment newInstance(String message) {
        ConfirmDialogFragment fragment = new ConfirmDialogFragment();
        Bundle args = new Bundle();
        args.putString("message", message);
        fragment.setArguments(args);
        return fragment;
    }
}
// Activity中展示
ConfirmDialogFragment.newInstance("确定删除吗?")
        .show(getSupportFragmentManager(), "confirm");

DialogFragment 的优势:

  • 配置变更(旋转屏)后对话框自动恢复
  • 生命周期可感知,与 Activity/Fragment 体系一致
  • 既可当对话框(onCreateDialog),也可当全屏弹层(onCreateView)

# 三、BottomSheetDialog 底部对话框

Material Design 风格的底部弹出面板,移动端常见的"分享面板""操作面板"场景:

// 需要Material组件库:implementation 'com.google.android.material:material:x.x.x'
BottomSheetDialog dialog = new BottomSheetDialog(this);
View view = LayoutInflater.from(this).inflate(R.layout.layout_share_sheet, null);

view.findViewById(R.id.item_wechat).setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        dialog.dismiss();  // 点击选项后关闭
        shareToWechat();
    }
});

dialog.setContentView(view);
dialog.show();

配套布局 layout_share_sheet.xml(横向排列分享项):

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center_horizontal"
    android:orientation="horizontal"
    android:padding="20dp">

    <LinearLayout android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:orientation="vertical">

        <ImageView android:id="@+id/item_wechat"
            android:layout_width="50dp"
            android:layout_height="50dp"
            android:src="@mipmap/ic_wechat" />

        <TextView android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="微信" />

    </LinearLayout>

    <!-- 其他分享项同理,省略 -->

</LinearLayout>

# 四、PopupWindow 浮层

PopupWindow 是锚定某个 View 的轻量浮层,非模态(不遮罩不阻断),适合下拉筛选、气泡提示等场景:

// 1. 准备浮层内容
View popupView = LayoutInflater.from(this).inflate(R.layout.layout_filter_popup, null);
popupView.findViewById(R.id.item_price).setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        filterByPrice();
        mPopupWindow.dismiss();
    }
});

// 2. 创建PopupWindow(宽高必须指定,否则不显示)
mPopupWindow = new PopupWindow(popupView,
        ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, true);
// true:点击外部区域关闭
mPopupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
mPopupWindow.setOutsideTouchable(true);

// 3. 在锚点View下方显示(还有showAsDropDown的重载控制偏移)
mPopupWindow.showAsDropDown(anchorView);
// 或指定重力与偏移:showAtLocation(parent, Gravity.CENTER, 0, 0)

AlertDialog 与 PopupWindow 的核心区别:

对比项 AlertDialog PopupWindow
模态 模态,阻断背后交互 非模态,背景可交互(可配置遮罩)
位置 屏幕中央(Material 风格) 锚定任意 View / 任意位置
生命周期 有 dismiss/cancel 回调 需手动管理,注意泄漏
典型场景 确认、选择 下拉筛选、气泡

PopupWindow 注意事项:不设置 setBackgroundDrawable 在部分机型上点击外部不关闭;在 Activity 销毁前要 dismiss(),否则可能造成窗口泄漏。

# 五、Toast 与 Snackbar 轻提示

# 5.1 Toast

系统级轻提示,短暂显示后自动消失,不占据页面布局:

// 标准用法:LENGTH_SHORT约2秒,LENGTH_LONG约3.5秒
Toast.makeText(context, "保存成功", Toast.LENGTH_SHORT).show();

注意事项:

  • context 建议用 Application Context:在非 Activity 场景(如回调里)用 Activity context 可能泄漏;Android 11+ 后台自定义 Toast 被系统限制
  • 自定义 View 的 Toast(setView)在 Android 11+ 已被限制,推荐用 Snackbar 替代
  • Toast 是队列机制:连续调用会排队依次显示,避免循环里刷 Toast

# 5.2 Snackbar

Material 风格的页面内提示,比 Toast 强在可携带操作按钮(典型场景:删除后提示"已删除"+ 撤销):

// 依赖Material组件库
Snackbar.make(findViewById(R.id.coordinator), "记录已删除", Snackbar.LENGTH_LONG)
        .setAction("撤销", new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                undoDelete();
            }
        })
        .show();

Toast vs Snackbar 选型:全局性、与页面布局无关的提示用 Toast;需要用户确认/撤销操作、希望与页面风格统一的用 Snackbar。

# 六、WindowManager 系统级悬浮窗

PopupWindow 只能在自己的 Activity 窗口内悬浮,而 WindowManager.addView 是向系统窗口层添加 View——它可以脱离 Activity 独立存在(Activity 关闭后仍在),悬浮在任何应用之上。全局悬浮球、视频小窗(画中画式)、来电浮层都是这个实现。

# 6.1 基本使用

public class FloatWindowService extends Service {
    private WindowManager mWindowManager;
    private View mFloatView;

    @Override
    public void onCreate() {
        super.onCreate();
        mWindowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE);

        // 1. 准备悬浮的View
        mFloatView = LayoutInflater.from(this).inflate(R.layout.layout_float_window, null);
        mFloatView.setOnTouchListener(new View.OnTouchListener() {
            // 跟手拖动等交互(结合08.01的触摸事件处理)
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                return handleDrag(event);
            }
        });

        // 2. 布局参数:type决定窗口层级,flags决定行为
        WindowManager.LayoutParams params = new WindowManager.LayoutParams();
        params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; // 8.0+的系统悬浮窗类型
        params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE      // 不抢焦点(背后应用可继续操作)
                | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
        params.format = PixelFormat.RGBA_8888;
        params.width = WindowManager.LayoutParams.WRAP_CONTENT;
        params.height = WindowManager.LayoutParams.WRAP_CONTENT;
        params.gravity = Gravity.START | Gravity.TOP;
        params.x = 0;
        params.y = 200;

        // 3. 添加到窗口(异步线程操作WindowManager需要先Looper.prepare,Service回调已在主线程无此问题)
        mWindowManager.addView(mFloatView, params);
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        // 必须移除,否则窗口泄漏
        if (mFloatView != null) {
            mWindowManager.removeView(mFloatView);
        }
    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }
}

# 6.2 悬浮窗权限(特殊权限)

系统悬浮窗需要 SYSTEM_ALERT_WINDOW 特殊权限,它不走普通的运行时权限申请,而是跳转系统设置页授权:

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
// 检查与引导授权(8.0+用canDrawOverlays判断)
if (!Settings.canDrawOverlays(this)) {
    Toast.makeText(this, "请开启悬浮窗权限", Toast.LENGTH_SHORT).show();
    startActivity(new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
            Uri.parse("package:" + getPackageName())));
}

# 6.3 与 PopupWindow 的本质区别

对比项 PopupWindow WindowManager 悬浮窗
窗口归属 依赖 Activity 窗口(token),Activity 关闭即消失 系统层独立窗口,跨应用存在
权限 无需权限 SYSTEM_ALERT_WINDOW 特殊权限
生命周期 跟随页面 需自行管理(通常挂 Service)
典型场景 页面内下拉/气泡 全局悬浮球、跨应用浮层、桌面歌词

注意事项:国内 ROM 对悬浮窗权限管控不一(部分需引导用户到厂商专属设置页);Service + WindowManager 是标准组合,注意 Service 被杀时清理窗口;8.0 以下旧 type(TYPE_PHONE 等)已废弃,统一用 TYPE_APPLICATION_OVERLAY。

# 七、Menu 菜单体系

菜单是"隐藏在按钮背后"的操作集合,分三类:

# 6.1 选项菜单(Options Menu)

Activity/Fragment 工具栏上的菜单(配合 ActionBar/Toolbar):

  1. 定义菜单资源:res/menu/menu_main.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <item android:id="@+id/action_search"
        android:title="搜索"
        android:icon="@mipmap/ic_search"
        app:showAsAction="always" />

    <item android:id="@+id/action_settings"
        android:title="设置"
        app:showAsAction="never" />

</menu>

showAsAction:always 直接显示在工具栏、never 收进溢出菜单(三点)、ifRoom 有空间就显示。

  1. Activity 中创建与响应:
@Override
public boolean onCreateOptionsMenu(Menu menu) {
    // 加载菜单资源
    getMenuInflater().inflate(R.menu.menu_main, menu);
    return true;
}

@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
    switch (item.getItemId()) {
        case R.id.action_search:
            openSearch();
            return true;  // 返回true表示已处理
        case R.id.action_settings:
            openSettings();
            return true;
        default:
            return super.onOptionsItemSelected(item);
    }
}

# 6.2 上下文菜单(Context Menu)

长按某个 View 弹出的菜单(如列表项长按操作):

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_demo);
    // 给目标View注册上下文菜单
    registerForContextMenu(findViewById(R.id.tv_target));
}

@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
    getMenuInflater().inflate(R.menu.menu_context, menu);
}

@Override
public boolean onContextItemSelected(@NonNull MenuItem item) {
    switch (item.getItemId()) {
        case R.id.action_copy:
            copyText();
            return true;
        default:
            return super.onContextItemSelected(item);
    }
}

# 6.3 弹出式菜单(PopupMenu)

锚定任意 View 弹出的菜单(替代"长按"的显式入口,如点击"⋮"按钮):

PopupMenu popup = new PopupMenu(this, anchorView);
popup.getMenuInflater().inflate(R.menu.menu_context, popup.getMenu());
popup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
    @Override
    public boolean onMenuItemClick(MenuItem item) {
        Log.i(TAG, "点击了:" + item.getTitle());
        return true;
    }
});
popup.show();

# 八、常见问题与陷阱

  1. 旋转屏对话框消失:直接持有 AlertDialog 引用的场景,改用 DialogFragment 承载。
  2. setMessage 与 setView 互斥:同时设置时 setView 优先生效,内容文字会被覆盖。
  3. Builder.create() 与 show() 的区别:show() 内部会 create+显示;需要提前拿到 dialog 引用控制关闭时用 create() + 手动 show()。
  4. PopupWindow 不显示:宽高必须是具体值(WRAP_CONTENT/MATCH_PARENT 或测量值),0 是不显示的。
  5. Toast 内存泄漏:非 Activity 场景用 Application Context;页面销毁时的 Toast 已入队无法取消,属可接受的轻量问题,避免在 onDestroy 中再发 Toast。
  6. 对话框按钮点击后自动关闭:如需"校验失败不关闭对话框"(如输入校验),不能依赖 Builder 的按钮回调(默认必关闭),要么用 dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener() 覆盖,要么用 DialogFragment 自行控制。
  7. 悬浮窗窗口泄漏:WindowManager.addView 之后 Activity/Service 销毁前必须 removeView;悬浮窗内部点击跳转 Activity 需加 FLAG_ACTIVITY_NEW_TASK(Service 无任务栈)。
  8. 悬浮窗权限厂商差异:Settings.canDrawOverlays 为 false 时优先引导授权;部分国产 ROM 授权入口不同,需按厂商做兜底跳转。

# 结束

本节介绍了 Android 弹层与菜单体系:AlertDialog 的四种形态(基本/列表/单选多选/自定义布局)、官方推荐的 DialogFragment 载体(解决旋转屏恢复问题)、BottomSheetDialog 底部面板、PopupWindow 锚定浮层、Toast 与 Snackbar 的选型差异、WindowManager 系统级悬浮窗(跨应用窗口与悬浮窗权限),以及选项/上下文/弹出式三套菜单的实现。选型原则记住一张图(开篇总览):模态确认用 AlertDialog(DialogFragment 承载),锚定浮层用 PopupWindow,全局悬浮用 WindowManager,轻反馈用 Toast/Snackbar。更多内容请参考官方文档:对话框 (opens new window)、菜单 (opens new window) 与 悬浮窗权限 (opens new window)。