第六讲 悬浮、弹窗与提示反馈

0 阅读5分钟

前言:

本讲聚焦Flutter中用户交互反馈类组件,这是很重要的一个交互窗口,完成这一讲,再看看15/16讲(存储相关),已经可以在不与后端交互的过程中,通过AI开发所有东西了。

一、本讲内容

  • 悬浮按钮(FloatingActionButton):提供页面核心功能的快捷入口
  • 对话框(AlertDialog/SimpleDialog):阻断式信息展示与操作确认
  • 底部弹窗(BottomSheet):非阻断式的底部操作面板
  • 提示组件(SnackBar/Toast):轻量级操作结果反馈

这些组件是构建「友好用户体验」的核心,几乎所有商用App(如电商、社交、工具类)都会高频使用。

结合状态管理(Provider/Bloc)实现弹窗状态控制、适配多端(移动端/桌面端)交互规范

  1. 组件层级

    1. 页面级组件(FAB/SnackBar/BottomSheet)依赖Scaffold
    2. 应用级组件(Dialog/Toast)挂载到Overlay
  2. 交互类型

    1. 阻断式(AlertDialog/SimpleDialog/ModalBottomSheet):需主动关闭
    2. 非阻断式(FAB/SnackBar/Toast):自动消失或不影响操作
  3. 使用场景

    1. FAB:页面核心功能快捷入口
    2. Dialog:二次确认/信息展示
    3. BottomSheet:多选项操作面板
    4. SnackBar/Toast:轻量级操作结果反馈

再次记忆:

  1. 组件层级

    1. 页面级组件(FAB/SnackBar/BottomSheet)依赖Scaffold
    2. 应用级组件(Dialog/Toast)挂载到Overlay
  2. 交互类型

    1. 阻断式(AlertDialog/SimpleDialog/ModalBottomSheet):需主动关闭
    2. 非阻断式(FAB/SnackBar/Toast):自动消失或不影响操作
  3. 使用场景

    1. FAB:页面核心功能快捷入口
    2. Dialog:二次确认/信息展示
    3. BottomSheet:多选项操作面板
    4. SnackBar/Toast:轻量级操作结果反馈

image.png

  1. 挂载层级

    1. 悬浮按钮/BottomSheet/SnackBar 依赖Scaffold容器,属于页面级组件
    2. 对话框/Toast 挂载到Overlay层(独立于页面的全局层),属于应用级组件
  2. 交互特性

    1. 阻断式组件(AlertDialog/SimpleDialog):阻塞页面其他操作,需用户主动关闭
    2. 非阻断式组件(SnackBar/BottomSheet/Toast/FAB):不影响页面其他操作

二、组件内容

2.1 FloatingActionButton(悬浮按钮)

核心属性
属性名作用常用值
onPressed点击回调() => {}
child按钮内容Icon/Text
backgroundColor背景色Colors.xxx
foregroundColor前景色Colors.xxx
elevation阴影高度0-24
shape按钮形状CircleBorder()/RoundedRectangleBorder()
mini是否迷你尺寸true/false
核心案例
import 'package:flutter/material.dart';

class FABExample extends StatelessWidget {
  const FABExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("悬浮按钮示例")),
      // 核心悬浮按钮
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 点击事件
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text("悬浮按钮被点击")),
          );
        },
        backgroundColor: Colors.blue, // 背景色
        foregroundColor: Colors.white, // 前景色(图标/文字)
        elevation: 8, // 阴影高度
        shape: RoundedRectangleBorder( // 自定义形状(默认圆形)
          borderRadius: BorderRadius.circular(16),
        ),
        child: const Icon(Icons.add), // 按钮内容
      ),
      // 按钮位置(默认右下角)
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
    );
  }
}

注意事项
  1. 必须挂载在ScaffoldfloatingActionButton属性上
  2. 避免设置过大尺寸,遵循Material Design规范
  3. 一个页面建议只放一个核心FAB,多个可使用FloatingActionButton.extendedBottomAppBar配合

2.2 AlertDialog/SimpleDialog(对话框)

核心属性
组件核心属性作用
AlertDialogtitle对话框标题
content对话框内容
actions底部操作按钮列表
backgroundColor对话框背景色
SimpleDialogtitle对话框标题
children选项列表(SimpleDialogOption)
titlePadding标题内边距
核心案例
class DialogExample extends StatelessWidget {
  const DialogExample({super.key});

  // 显示AlertDialog
  void _showAlertDialog(BuildContext context) {
    showDialog(
      context: context,
      barrierDismissible: false, // 点击空白处是否关闭(false=不关闭)
      builder: (context) {
        return AlertDialog(
          title: const Text("确认删除?"), // 标题
          content: const Text("删除后数据将无法恢复,是否确认?"), // 内容
          // 操作按钮
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context), // 关闭对话框
              child: const Text("取消"),
            ),
            TextButton(
              onPressed: () {
                Navigator.pop(context);
                ScaffoldMessenger.of(context).showSnackBar(
                  const SnackBar(content: Text("数据已删除")),
                );
              },
              style: TextButton.styleFrom(foregroundColor: Colors.red),
              child: const Text("确认"),
            ),
          ],
        );
      },
    );
  }

  // 显示SimpleDialog(选择型对话框)
  void _showSimpleDialog(BuildContext context) {
    showDialog(
      context: context,
      builder: (context) {
        return SimpleDialog(
          title: const Text("选择语言"),
          children: [
            SimpleDialogOption(
              onPressed: () => Navigator.pop(context, "中文"),
              child: const Text("中文"),
            ),
            SimpleDialogOption(
              onPressed: () => Navigator.pop(context, "英文"),
              child: const Text("英文"),
            ),
            SimpleDialogOption(
              onPressed: () => Navigator.pop(context, "日语"),
              child: const Text("日语"),
            ),
          ],
        );
      },
    ).then((value) {
      if (value != null) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text("你选择了:$value")),
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("对话框示例")),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => _showAlertDialog(context),
              child: const Text("显示确认对话框"),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () => _showSimpleDialog(context),
              child: const Text("显示选择对话框"),
            ),
          ],
        ),
      ),
    );
  }
}

注意事项
  1. 通过showDialog方法触发,需传入context
  2. 关闭对话框必须调用Navigator.pop(context)
  3. barrierDismissible设为false时,必须提供关闭按钮
  4. SimpleDialog适合「单选场景」,AlertDialog适合「确认场景」

2.3 BottomSheet(底部弹窗)

核心属性
属性名作用常用值
isScrollControlled是否可滚动(适配高度)true/false
shape弹窗形状RoundedRectangleBorder()
backgroundColor背景色Colors.xxx
elevation阴影高度0-24
builder弹窗内容构建器(context) => Widget
核心案例
class BottomSheetExample extends StatelessWidget {
  const BottomSheetExample({super.key});

  void _showBottomSheet(BuildContext context) {
    // 模态底部弹窗(阻断式)
    showModalBottomSheet(
      context: context,
      isScrollControlled: true, // 是否可滚动(适配软键盘)
      shape: const RoundedRectangleBorder( // 顶部圆角
        borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
      ),
      builder: (context) {
        return SizedBox(
          height: 220,
          child: Column(
            children: [
              const Padding(
                padding: EdgeInsets.all(16),
                child: Text("底部操作面板", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
              ),
              ListTile(
                leading: const Icon(Icons.share),
                title: const Text("分享"),
                onTap: () {
                  Navigator.pop(context);
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(content: Text("分享成功")),
                  );
                },
              ),
              ListTile(
                leading: const Icon(Icons.delete),
                title: const Text("删除"),
                textColor: Colors.red,
                onTap: () {
                  Navigator.pop(context);
                  _showDeleteConfirm(context);
                },
              ),
              ListTile(
                leading: const Icon(Icons.cancel),
                title: const Text("取消"),
                onTap: () => Navigator.pop(context),
              ),
            ],
          ),
        );
      },
    );
  }

  void _showDeleteConfirm(BuildContext context) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text("确认删除?"),
        content: const Text("是否确认删除该内容?"),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context), child: const Text("取消")),
          TextButton(onPressed: () {
            Navigator.pop(context);
            ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("删除成功")));
          }, child: const Text("确认")),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("底部弹窗示例")),
      body: Center(
        child: ElevatedButton(
          onPressed: () => _showBottomSheet(context),
          child: const Text("显示底部弹窗"),
        ),
      ),
    );
  }
}

注意事项
  1. showModalBottomSheet是阻断式弹窗,需主动关闭
  2. 内容超过屏幕高度时,需嵌套ListViewSingleChildScrollView
  3. 非模态BottomSheet可通过ScaffoldbottomSheet属性实现

2.4 SnackBar/Toast(提示反馈)

核心属性
组件核心属性作用
SnackBarcontent提示文本
backgroundColor背景色
duration显示时长
action右侧操作按钮
behavior显示位置
Toast(自定义)message提示文本
duration显示时长
position显示位置
核心案例
// 先实现Toast(Flutter无原生Toast,自定义实现)
class Toast {
  static void show(BuildContext context, String message) {
    final overlay = Overlay.of(context);
    final overlayEntry = OverlayEntry(
      builder: (context) => Positioned(
        bottom: 50,
        left: MediaQuery.of(context).size.width * 0.1,
        right: MediaQuery.of(context).size.width * 0.1,
        child: Material(
          borderRadius: BorderRadius.circular(8),
          color: Colors.black87,
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: Text(
              message,
              textAlign: TextAlign.center,
              style: const TextStyle(color: Colors.white),
            ),
          ),
        ),
      ),
    );

    overlay.insert(overlayEntry);
    // 2秒后移除
    Future.delayed(const Duration(seconds: 2), () => overlayEntry.remove());
  }
}

class FeedbackExample extends StatelessWidget {
  const FeedbackExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("提示反馈示例")),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // SnackBar示例
            ElevatedButton(
              onPressed: () {
                ScaffoldMessenger.of(context)
                  ..hideCurrentSnackBar() // 隐藏之前的SnackBar
                  ..showSnackBar(
                    SnackBar(
                      content: const Text("操作成功!"),
                      backgroundColor: Colors.green,
                      duration: const Duration(seconds: 3), // 显示时长
                      action: SnackBarAction( // 可操作的SnackBar
                        label: "撤销",
                        textColor: Colors.white,
                        onPressed: () {
                          Toast.show(context, "已撤销操作");
                        },
                      ),
                    ),
                  );
              },
              child: const Text("显示SnackBar"),
            ),
            const SizedBox(height: 20),
            // Toast示例
            ElevatedButton(
              onPressed: () => Toast.show(context, "这是一个Toast提示"),
              child: const Text("显示Toast"),
            ),
          ],
        ),
      ),
    );
  }
}

注意事项
  1. SnackBar必须通过ScaffoldMessenger展示,依赖Scaffold
  2. SnackBar默认显示在底部,可通过behavior改为悬浮式
  3. Flutter无原生Toast,建议使用第三方库(如fluttertoast)或自定义
  4. Toast是轻量级提示,不建议放复杂操作,显示时长控制在1-3秒

三、应用案例

实现一个「待办事项」页面,包含:

  • 悬浮按钮:添加新待办
  • 点击待办:弹出确认对话框
  • 操作结果:SnackBar/Toast提示
  • 更多操作:底部弹窗展示
import 'package:flutter/material.dart';

// 自定义Toast组件
class Toast {
  static void show(BuildContext context, String message) {
    final overlay = Overlay.of(context);
    final overlayEntry = OverlayEntry(
      builder: (context) => Positioned(
        bottom: 50,
        left: MediaQuery.of(context).size.width * 0.1,
        right: MediaQuery.of(context).size.width * 0.1,
        child: Material(
          borderRadius: BorderRadius.circular(8),
          color: Colors.black87,
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: Text(
              message,
              textAlign: TextAlign.center,
              style: const TextStyle(color: Colors.white, fontSize: 14),
            ),
          ),
        ),
      ),
    );

    overlay.insert(overlayEntry);
    Future.delayed(const Duration(seconds: 2), () => overlayEntry.remove());
  }
}

// 待办事项模型
class Todo {
  final String title;
  final bool isCompleted;

  Todo({required this.title, this.isCompleted = false});
}

class TodoApp extends StatefulWidget {
  const TodoApp({super.key});

  @override
  State<TodoApp> createState() => _TodoAppState();
}

class _TodoAppState extends State<TodoApp> {
  final List<Todo> _todos = [    Todo(title: "学习Flutter弹窗组件"),    Todo(title: "完成作业", isCompleted: true),    Todo(title: "整理笔记"),  ];

  // 添加新待办
  void _addNewTodo() {
    showDialog(
      context: context,
      builder: (context) {
        final TextEditingController controller = TextEditingController();
        return AlertDialog(
          title: const Text("添加新待办"),
          content: TextField(
            controller: controller,
            decoration: const InputDecoration(hintText: "请输入待办内容"),
          ),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: const Text("取消"),
            ),
            TextButton(
              onPressed: () {
                if (controller.text.trim().isEmpty) {
                  Toast.show(context, "内容不能为空");
                  return;
                }
                setState(() {
                  _todos.add(Todo(title: controller.text.trim()));
                });
                Navigator.pop(context);
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(
                    content: Text("已添加:${controller.text.trim()}"),
                    backgroundColor: Colors.green,
                  ),
                );
              },
              child: const Text("添加"),
            ),
          ],
        );
      },
    );
  }

  // 显示更多操作底部弹窗
  void _showMoreActions(BuildContext context, Todo todo) {
    showModalBottomSheet(
      context: context,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
      ),
      builder: (context) => SizedBox(
        height: 180,
        child: Column(
          children: [
            const Padding(
              padding: EdgeInsets.all(16),
              child: Text("操作选项", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            ),
            ListTile(
              leading: Icon(todo.isCompleted ? Icons.check_circle : Icons.radio_button_unchecked),
              title: Text(todo.isCompleted ? "标记为未完成" : "标记为完成"),
              onTap: () {
                Navigator.pop(context);
                setState(() {
                  _todos[_todos.indexOf(todo)] = Todo(
                    title: todo.title,
                    isCompleted: !todo.isCompleted,
                  );
                });
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(
                    content: Text(todo.isCompleted ? "已标记为未完成" : "已标记为完成"),
                  ),
                );
              },
            ),
            ListTile(
              leading: const Icon(Icons.delete, color: Colors.red),
              title: const Text("删除", style: TextStyle(color: Colors.red)),
              onTap: () {
                Navigator.pop(context);
                _confirmDelete(context, todo);
              },
            ),
          ],
        ),
      ),
    );
  }

  // 确认删除对话框
  void _confirmDelete(BuildContext context, Todo todo) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text("确认删除?"),
        content: Text("是否删除待办:${todo.title}"),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text("取消"),
          ),
          TextButton(
            onPressed: () {
              setState(() {
                _todos.remove(todo);
              });
              Navigator.pop(context);
              Toast.show(context, "已删除:${todo.title}");
            },
            style: TextButton.styleFrom(foregroundColor: Colors.red),
            child: const Text("删除"),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("待办事项管理"),
        actions: [
          IconButton(
            icon: const Icon(Icons.more_vert),
            onPressed: () => Toast.show(context, "更多功能开发中"),
          ),
        ],
      ),
      body: _todos.isEmpty
          ? const Center(child: Text("暂无待办事项,点击右下角添加"))
          : ListView.builder(
              itemCount: _todos.length,
              itemBuilder: (context, index) {
                final todo = _todos[index];
                return ListTile(
                  leading: Icon(
                    todo.isCompleted ? Icons.check_circle : Icons.radio_button_unchecked,
                    color: todo.isCompleted ? Colors.green : Colors.grey,
                  ),
                  title: Text(
                    todo.title,
                    style: TextStyle(
                      decoration: todo.isCompleted ? TextDecoration.lineThrough : null,
                      color: todo.isCompleted ? Colors.grey : Colors.black,
                    ),
                  ),
                  trailing: const Icon(Icons.arrow_forward_ios, size: 16),
                  onTap: () => _showMoreActions(context, todo),
                );
              },
            ),
      // 悬浮按钮
      floatingActionButton: FloatingActionButton(
        onPressed: _addNewTodo,
        backgroundColor: Colors.blue,
        child: const Icon(Icons.add),
      ),
    );
  }
}

// 入口函数
void main() {
  runApp(const MaterialApp(
    home: TodoApp(),
    debugShowCheckedModeBanner: false,
  ));
}

  1. 首页:展示待办事项列表,右下角有添加悬浮按钮
  2. 添加待办:点击悬浮按钮弹出输入对话框,输入完成后SnackBar提示
  3. 操作待办:点击待办项弹出底部操作弹窗,可标记完成/未完成、删除
  4. 删除确认:点击删除后弹出确认对话框,确认后Toast提示删除成功
  5. 空内容提示:添加待办时内容为空,Toast提示错误信息