Flutter进阶(8):全局浮层系统(Overlay)

Flutter中的 Overlay 是一种用于在应用程序的 UI 堆栈上显示临时内容的机制。它允许开发者在不影响现有 UI 结构的情况下,将新的 UI 元素叠加在现有内容之上。以下是 Overlay 的详细使用,包含基础到高级的用法。


一、Overlay 基础概念

Overlay 是一个可以包含多个叠加层的 Stack 栈结构,每个层都可以独立地显示和隐藏。这些层可以是模态对话框、提示框、加载指示器等。这些界面始终显示在屏幕最上层。Overlay 是一个特殊的 Stack,它由 OverlayEntry 组成,每个 OverlayEntry 代表一个浮动层。Flutter 应用中默认有一个全局的 Overlay,可以通过 Overlay.of(context) 获取。


优点

  • 灵活性:Overlay 允许在任何地方动态添加和移除 UI 元素,而不需要修改现有的 Widget 树。
  • 非侵入性:由于 Overlay 层是独立于主 UI 树的,它们不会干扰到其他部分的布局和交互。
  • 易于管理:通过 OverlayEntry 对象,可以方便地对每个叠加层进行控制,如显示、隐藏和更新。

缺点

  • 需要手动管理 OverlayEntry,增加了代码复杂度。
  • 不当的管理可能导致内存泄漏。

类型

Flutter 中的 Overlay 主要分为两种类型:

  • 系统级 Overlay:由 Flutter 框架自动管理的,如路由动画、提示框等。
  • 自定义 Overlay:开发者可以根据需要创建和管理的 Overlay 层。

应用场景

  • 模态对话框:用于显示需要用户关注的提示信息或操作确认。
  • 加载指示器:在执行耗时操作时,显示加载状态给用户。
  • 悬浮工具栏:在屏幕边缘显示一些常用功能的快捷方式。
  • 弹出菜单:点击某个元素后,显示相关的选项列表。

二、基本使用流程

先介绍一下 Overlay 的核心类:

  • Overlay(叠加层控件):Flutter中的 Overlay 控件是一个 Widget,它用于创建和管理叠加层。它本身不可见,但可以包含多个可见的 OverlayEntry。
  • OverlayEntry(叠加层条目):OverlayEntry 是 Overlay 中的条目,它代表了要在叠加层上显示的内容。每个 OverlayEntry 都可以包含一个 Widget,并定义了在叠加层上的位置和大小。
  • OverlayState(叠加层状态):OverlayState 是与 Overlay 关联的状态对象,它用于添加、移除和管理 OverlayEntry。可以通过 BuildContext 的方法来获取 OverlayState 对象。

另外:

  • 添加和移除OverlayEntry:使用 OverlayState 对象的方法,可以将 OverlayEntry 添加到 Overlay 中,并通过调用 remove 方法将其从 Overlay 中移除。

  • OverlayEntry的位置和大小:OverlayEntry 可以通过设置 Positioned 或 Align 等控件来确定其在叠加层上的位置和大小。可以使用 top、bottom、left、right 等属性来定位 OverlayEntry。


(1)获取 Overlay 的三种方式

// 方式1:通过 BuildContext 获取
OverlayState overlayState = Overlay.of(context);

// 方式2:通过 GlobalKey 获取
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
OverlayState overlayState = navigatorKey.currentState!.overlay;

// 方式3:通过 Navigator 获取
OverlayState overlayState = Navigator.of(context).overlay!;

(2)创建OverlayEntry

// 创建 OverlayEntry
OverlayEntry entry = OverlayEntry(
  builder: (context) => Positioned(
    top: 100,
    left: 50,
    child: Material(
      elevation: 4,
      child: Container(
        padding: EdgeInsets.all(16),
        child: Text('浮动内容'),
      ),
    ),
  ),
  // 可选参数
  opaque: false,  // 是否不透明
  maintainState: true,  // 是否保持状态
);

(3)插入 OverlayEntry

// 插入到 Overlay
overlayState.insert(entry);

// 不创建 OverlayState 的方式,直接插入到 Overlay
Overlay.of(context).insert(entry);

实际使用中,也可以选择不创建 OverlayState,而是直接使用 Overlay.of(context).insert(entry); 来插入 Overlay。因为 OverlayState overlayState = Overlay.of(context);


(4)移除 OverlayEntry

// 直接移除
entry.remove();

// 延时3秒移除
Future.delayed(const Duration(seconds: 3), () {
  entry.remove();
});

三、实战案例

3.1 案例1:显示悬浮按钮

这个例子要实现点击一个按钮会打开 Overlay 显示一个 FloatingActionButton(使用了OverlayState):

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: OverlayExample(), // 使用 OverlayExample 作为主页
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Overlay 示例'),
      ),
      body: Center(
        child: ElevatedButton(
          child: const Text('显示Overlay'),
          onPressed: () {
            showFloatingButtonOverlay(context);
          },
        ),
      ),
    );
  }
}

void showFloatingButtonOverlay(BuildContext context) {
  OverlayState? overlayState = Overlay.of(context);
  late OverlayEntry overlayEntry;

  // 创建 OverlayEntry
  overlayEntry = OverlayEntry(
    builder: (BuildContext context) {
      return Positioned(
        top: 100,
        right: 16,
        child: FloatingActionButton(
          onPressed: () {
            // 悬浮按钮被点击
            debugPrint('Floating Button Clicked');
            overlayEntry.remove(); // 移除 OverlayEntry
          },
          child: const Icon(Icons.add),
        ),
      );
    },
  );

  // 将 OverlayEntry 添加到 Overlay 中
  overlayState?.insert(overlayEntry);
}

运行起来的效果如图:

Flutter_Overlay_A.png


点击 "显示Overlay按钮" 后的效果图如下:

Flutter_Overlay_B.png


3.2 案例2:显示悬浮提示框

要使用 Overlay,我们需要通过 OverlayEntry 来定义每一个悬浮的 widget,并将它们插入到 Overlay 中。

以下是一个简单的示例,展示了如何使用 Overlay 来显示一个悬浮的提示框:

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Overlay 示例'),
        ),
        body: const OverlayExample(),
      ),
    );
  }
}

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

  @override
  // ignore: library_private_types_in_public_api
  _OverlayExampleState createState() => _OverlayExampleState();
}

class _OverlayExampleState extends State<OverlayExample> {
  OverlayEntry? _overlayEntry; // OverlayEntry对象定义

  @override
  Widget build(BuildContext context) {
    return Center(
      child: ElevatedButton(
        onPressed: () {
          _showOverlay(context); // 按钮点击,显示Overlay弹窗
        },
        child: const Text('显示Overlay'),
      ),
    );
  }

  // Overlay显示函数
  void _showOverlay(BuildContext context) {
    // 创建OverlayEntry
    _overlayEntry = OverlayEntry(
      builder: (context) => Positioned(
        top: 100,
        left: 300,
        child: Material(
          color: Colors.transparent,
          child: Container(
            color: Colors.black54,
            width: 200,
            height: 100,
            child: const Center(
              child: Text(
                '这是一个Overlay',
                style: TextStyle(color: Colors.white, fontSize: 20),
              ),
            ),
          ),
        ),
      ),
    );

    // 不创建 OverlayState 的方式,直接插入OverlayEntry到Overlay中
    Overlay.of(context).insert(_overlayEntry!);

    // 3秒后移除OverlayEntry
    Future.delayed(const Duration(seconds: 3), () {
      _overlayEntry?.remove();
      _overlayEntry = null;
    });
  }
}

在上述代码中,我们通过创建一个 OverlayEntry 并使用 Overlay.of(context)?.insert(_overlayEntry!) 将其插入到 Overlay 中。3 秒后,我们移除了这个 OverlayEntry。

运行起来的效果如图:

Flutter_Overlay_C.png


点击 "显示Overlay按钮" 后的效果图如下:

Flutter_Overlay_D.png


3.3 案例3:显示全局 Toast 组件

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Overlay 示例'),
        ),
        body: const OverlayExample(),
      ),
    );
  }
}

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

  @override
  // ignore: library_private_types_in_public_api
  _OverlayExampleState createState() => _OverlayExampleState();
}

class _OverlayExampleState extends State<OverlayExample> {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: ElevatedButton(
        onPressed: () {
          Toast.show(context, '操作成功'); // 按钮点击,显示Overlay弹窗
        },
        child: const Text('显示Overlay'),
      ),
    );
  }
}

// 全局 Toast 组件实现
class Toast {
  static void show(BuildContext context, String message) {
    final overlay = Overlay.of(context);
    final overlayEntry = OverlayEntry(
      builder: (context) => Positioned(
        top: 130,
        left: MediaQuery.of(context).size.width * 0.4 + 10,
        child: Material(
          color: Colors.transparent,
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
            decoration: BoxDecoration(
              color: Colors.black87,
              borderRadius: BorderRadius.circular(20),
            ),
            child: Text(
              message,
              style: const TextStyle(color: Colors.white),
            ),
          ),
        ),
      ),
    );
    
    overlay.insert(overlayEntry);
    Future.delayed(const Duration(seconds: 2)).then((_) {
      overlayEntry.remove();
    });
  }
}

运行起来的效果如图:

Flutter_Overlay_E.png


点击 "显示Overlay按钮" 后的效果图如下:

Flutter_Overlay_F.png


3.4 案例3:显示自定义弹出菜单

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Overlay 示例'),
        ),
        body: const OverlayExample(),
      ),
    );
  }
}

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

  @override
  // ignore: library_private_types_in_public_api
  _OverlayExampleState createState() => _OverlayExampleState();
}

class _OverlayExampleState extends State<OverlayExample> {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: ElevatedButton(
        onPressed: () {
          showContextMenu(context, const Offset(300, 100)); // 按钮点击,显示Overlay弹窗
        },
        child: const Text('显示Overlay'),
      ),
    );
  }
}

// 自定义弹出菜单
void showContextMenu(BuildContext context, Offset tapPosition) {
  final overlay = Overlay.of(context);
  final renderBox = context.findRenderObject() as RenderBox;
  renderBox.localToGlobal(Offset.zero);
  
  OverlayEntry? entry;
  
  entry = OverlayEntry(
    builder: (context) => GestureDetector(
      behavior: HitTestBehavior.translucent,
      onTap: () => entry?.remove(),
      child: Stack(
        children: [
          Positioned(
            left: tapPosition.dx,
            top: tapPosition.dy,
            child: Material(
              elevation: 8,
              child: ConstrainedBox(
                constraints: BoxConstraints(
                  maxWidth: MediaQuery.of(context).size.width * 0.7,
                ),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    _buildMenuItem('复制', Icons.copy),
                    _buildMenuItem('分享', Icons.share),
                    _buildMenuItem('删除', Icons.delete),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    ),
  );
  
  overlay.insert(entry);
}

Widget _buildMenuItem(String text, IconData icon) {
  return InkWell(
    onTap: () {
      debugPrint('选择了: $text');
    },
    child: Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      width: 160,
      child: Row(
        children: [
          Icon(icon, size: 20),
          const SizedBox(width: 8),
          Text(text),
        ],
      ),
    ),
  );
}

运行起来的效果如图:

Flutter_Overlay_G.png


点击 "显示Overlay按钮" 后的效果图如下:

Flutter_Overlay_H.png


3.5 案例5:显示可拖拽浮动按钮

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Overlay 示例'),
        ),
        body:  const DraggableOverlayButton(),
      ),
    );
  }
}

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

  @override
  // ignore: library_private_types_in_public_api
  _DraggableOverlayButtonState createState() => _DraggableOverlayButtonState();
}

class _DraggableOverlayButtonState extends State<DraggableOverlayButton> {
  late OverlayEntry _entry;
  Offset _position = const Offset(300, 200);

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _showOverlay();
    });
  }

  void _showOverlay() {
    _entry = OverlayEntry(
      builder: (context) => Positioned(
        left: _position.dx,
        top: _position.dy,
        child: GestureDetector(
          onPanUpdate: (details) {
            setState(() {
              _position += details.delta;
              _entry.markNeedsBuild();
            });
          },
          child: Material(
            elevation: 8,
            shape: const CircleBorder(),
            child: FloatingActionButton(
              onPressed: () {
                debugPrint('浮动按钮点击');
              },
              child: const Icon(Icons.add),
            ),
          ),
        ),
      ),
    );
    Overlay.of(context).insert(_entry);
  }

  @override
  void dispose() {
    _entry.remove();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(); // 空容器,实际内容在 Overlay 中
  }
}

效果图如下所示:

Flutter_Overlay_I.png


四、全局模态窗口完整实现实例

下面我将提供一个完整的、可直接使用的全局模态窗口实现,包含拖拽功能、自适应尺寸等特性。先看下效果图:

Flutter_Overlay_A.gif


功能特性

  • 多种显示方式

    • 默认居中显示
    • 可自定义尺寸和位置
    • 支持全屏显示
  • 交互功能

    • 点击外部关闭(可配置)
    • 拖拽移动窗口(可配置)
    • 内置关闭按钮
  • 视觉效果

    • 阴影和圆角效果
    • 半透明背景遮罩
  • 状态管理

    • 自动管理 OverlayEntry
    • 防止重复打开
    • 全局关闭方法

(1)创建全局模态窗口管理器

文件名 global_manager.dart

import 'package:flutter/material.dart';

class GlobalModal {
  static OverlayEntry? _currentEntry;

  /// 显示全局模态窗口
  static void show({
    required BuildContext context, // 窗口上下文
    required Widget child, // 窗口内容
    Size? size, // 窗口尺寸
    Offset? position, // 窗口左上角坐标
    Color barrierColor = Colors.black54, // 背景色
    bool barrierDismissible = true, // 点击背景是否关闭
    bool draggable = false, // 是否允许拖拽
    Duration animationDuration = const Duration(milliseconds: 300),
    Curve animationCurve = Curves.easeOutBack,
  }) {
    // 如果已有窗口显示,先关闭
    if (_currentEntry != null) {
      _currentEntry?.remove();
      _currentEntry = null;
    }

    final overlayState = Overlay.of(context);
    final mediaQuery = MediaQuery.of(context);
    
    // 默认尺寸为屏幕的80%
    final modalSize = size ?? Size(
      mediaQuery.size.width * 0.8,
      mediaQuery.size.height * 0.8,
    );
    
    // 默认居中显示
    final modalPosition = position ?? Offset(
      (mediaQuery.size.width - modalSize.width) / 2,
      (mediaQuery.size.height - modalSize.height) / 2,
    );

    Offset currentPosition = modalPosition; // 模态窗口的当前坐标

    _currentEntry = OverlayEntry(
      builder: (context) {
        return Stack(
          children: [
            // 半透明背景层(遮罩层)
            Positioned.fill(
              child: GestureDetector(
                onTap: barrierDismissible ? () => dismiss() : null, // 控制点击遮罩层是否销毁该模态窗口
                child: Container(color: barrierColor), // 背景色
              ),
            ),

            // 模态窗口内容
            Positioned(
                  left: currentPosition.dx,
                  top: currentPosition.dy,
                  child: Material(
                  elevation: 8,
                  borderRadius: BorderRadius.circular(12),
                  child: Container(
                    width: modalSize.width, // 尺寸
                    height: modalSize.height,
                    decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(12),
                    ),
                    child: Column(
                      children: [
                        // 拖拽手柄区域
                        if (draggable)
                          GestureDetector(
                            onPanUpdate: (details) {
                              currentPosition += details.delta;
                              _currentEntry?.markNeedsBuild();
                            },
                            child: Container(
                              height: 40,
                              decoration: BoxDecoration(
                                color: Colors.grey[200],
                                borderRadius: const BorderRadius.vertical(
                                  top: Radius.circular(12),
                                ),
                              ),
                              child: const Center(
                                child: Icon(
                                  Icons.drag_handle,
                                  color: Colors.grey,
                                ),
                              ),
                            ),
                          ),
                        
                        // 关闭按钮
                        const Align(
                          alignment: Alignment.topRight,
                          child: IconButton(
                            icon: Icon(Icons.close),
                            onPressed: dismiss,
                          ),
                        ),
                        
                        // 内容区域
                        Expanded(
                          child: Padding(
                            padding: const EdgeInsets.all(16),
                            child: child, // 内容窗口
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
               ),
          // //   ),
           ],
        );
      },
    );

    overlayState.insert(_currentEntry!);
  }

  /// 关闭当前模态窗口
  static void dismiss() {
    if (_currentEntry != null) {
      _currentEntry?.remove();
      _currentEntry = null;
    }
  }
}

(2)使用示例

文件名 main.dart

import 'package:flutter/material.dart';
import './global_manager.dart';

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Overlay 示例'),
        ),
        body:  const GlobalModalExample(),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('全局模态窗口示例')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              child: const Text('基本模态窗口'),
              onPressed: () => GlobalModal.show(
                context: context,
                child: const Center(
                  child: Text('这是一个基本模态窗口', style: TextStyle(fontSize: 20)),
                ),
              ),
            ),
            
            const SizedBox(height: 20),
            
            ElevatedButton(
              child: const Text('自定义尺寸模态窗口'),
              onPressed: () => GlobalModal.show(
                context: context,
                size: const Size(300, 400),
                child: ListView.builder(
                  itemCount: 20,
                  itemBuilder: (_, index) => ListTile(
                    title: Text('项目 $index'),
                  ),
                ),
              ),
            ),
            
            const SizedBox(height: 20),
            
            ElevatedButton(
              child: const Text('可拖拽模态窗口'),
              onPressed: () => GlobalModal.show(
                context: context,
                draggable: true,
                child: const Column(
                  children: [
                    Text('可拖拽窗口', style: TextStyle(fontSize: 20)),
                    SizedBox(height: 20),
                    ElevatedButton(
                      onPressed: GlobalModal.dismiss,
                      child: Text('关闭'),
                    ),
                  ],
                ),               
              ),
            ),
            
            const SizedBox(height: 20),
            
            ElevatedButton(
              child: const Text('禁止点击外部关闭'),
              onPressed: () => GlobalModal.show(
                context: context,
                barrierDismissible: false,
                child: const Center(
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Text('必须点击按钮关闭', style: TextStyle(fontSize: 20)),
                      SizedBox(height: 20),
                      ElevatedButton(
                        onPressed: GlobalModal.dismiss,
                        child: Text('关闭窗口'),
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

参考:

Flutter悬浮UI的设计Overlay组件_flutter overlay-CSDN博客

Flutter的Overlay,你飘了。全局提示,悬浮按钮,弹出菜单在Flutter开发中,我们经常会遇到需要在当前页面 - 掘金


posted @ 2025-05-29 16:48  fengMisaka  阅读(1030)  评论(0)    收藏  举报