flutter:全局 context 在 navigator 与 overlay 中的运用

1,967 阅读3分钟

引言:什么是 context

flutter 中的概念 '万物皆 widget '。flutter 的UI布局由一个个 widget 叠加组合而成,而每一个 widget 都会对应 一个 Element, Element 类内部会实现 BuildContext 接口。编码中,我们使用的 context 指向 widget 树中的具体 UI 节点。

image.png

context 在项目中的使用场景

在 flutter 编程中,我们常用 context 来实现如下功能:

  1. navigator 导航进行页面的跳转。
Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => PageA(),
      ),
    )
  1. 弹窗 Dialog
showDialog(context: context, builder: (context) {
      return Dialog(
        child: Page(),
      );
    })
  1. 获取主题配置
final themeData = Theme.of(context);
  1. 添加 Overlay 图层 (常用于自定义 loading,toast 等能力支持)
Overlay.of(context)?.insert(? extends OverlayEntry);

如何维护一个全局 context

利用 MaterialApp 节点的 navigatorKey 属性,维护一个适用于全局的 BuildContext。在需要使用 context 的地方可直接使用全局维护的 context 进行页面跳转,showLoading,toast 等功能实现。

创建一个维护 GlobalContext 的工具类 NavigatorProvider (可直接copy使用)

import 'package:flutter/material.dart';

/// 用于提供全局的 navigatorContext
class NavigatorProvider {
  final GlobalKey<NavigatorState> _navigatorKey = new GlobalKey<NavigatorState>(debugLabel: 'Rex');

  static final NavigatorProvider _instance = NavigatorProvider._();

  NavigatorProvider._();

  /// 赋值给根布局的 materialApp 上
  /// navigatorKey.currentState.pushName('url') 可直接用于跳转
  static GlobalKey<NavigatorState> get navigatorKey => _instance._navigatorKey;

  /// 可用于 跳转,overlay-insert(toast,loading) 使用
  static BuildContext? get navigatorContext => _instance._navigatorKey.currentState?.context;
}

在 MaterialApp 根节点上进行应用注册 navigatorKey

void main() {
  runApp(
    MaterialApp( //为什么这里会嵌套两层 MaterialApp,我们在下面进行解说
      home: MaterialApp(
        navigatorKey: NavigatorProvider.navigatorKey,
        routes: {
          '/pageA': (BuildContext context) => PageA(),
        },
        home: Scaffold(
          body: TestNavigatorWidget(),
        ),
      ),
    ),
  );
}

TestNavigatorWidget 是小编写的一个测试页面,页面内包含两个功能按键(1. 跳转页面。 2. toast )

class TestNavigatorWidget extends StatelessWidget {
  const TestNavigatorWidget({Key? key}) : super(key: key);

  ///跳转页面
  void _jumpPageA() {
    NavigatorProvider.navigatorKey.currentState?.pushNamed('/pageA');
  }

  ///toast
  void _overLayToast() {
    final globalContext = NavigatorProvider.navigatorContext;
    if (globalContext != null) {
      ToastUtils.toast('首页的 toast', globalContext);
    }
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextButton(
              onPressed: _overLayToast,
              child: Text('toast'),
            ),
            TextButton(
              onPressed: _jumpPageA,
              child: Text('跳转下一个页面'),
            ),
          ],
        ),
      ),
    );
  }
}
class PageA extends StatelessWidget {
  const PageA({Key? key}) : super(key: key);

  ///toast
  void _overLayToast() {
    final globalContext = NavigatorProvider.navigatorContext;
    if (globalContext != null) {
      ToastUtils.toast('PageA的 toast', globalContext);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('PageA')),
      body: Center(
        child: TextButton(
          onPressed: _overLayToast,
          child: Text('PageA toast'),
        ),
      ),
    );
  }
}

效果图如下:

1650768280690.gif

代码分析

    1. 可直接使用全局 context 进行页面跳转 NavigatorProvider.navigatorKey.currentState?.pushNamed('/pageA')
    1. ToastUtils 是小编封装的 toast 工具类,借助三方库 fluttertoast: ^8.0.9 使用 overlay 图层添加的原理实现。工具类中使用的 context 正是在根节点中注册的NavigatorProvider 提供的全局 context。
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';

class ToastUtils {
  late FToast _fToast;
  static ToastUtils _instance = ToastUtils._();

  ToastUtils._() {
    _fToast = FToast();
  }

  static void toast(String message, BuildContext context) {
    _instance._fToast.init(context);
    _instance._fToast.showToast(
      child: _ToastEntry(message),
      gravity: ToastGravity.BOTTOM,
      toastDuration: Duration(seconds: 2),
    );
  }

///toast使用的UI
class _ToastEntry extends StatelessWidget {
  final String message;

  const _ToastEntry(this.message, {Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(
        horizontal: 24.0,
        vertical: 12.0,
      ),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(25.0),
        color: Colors.greenAccent,
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(Icons.check),
          SizedBox(width: 12.0),
          Text(message),
        ],
      ),
    );
  }
}

使用这段代码的同学别忘了在 pubspec.yaml 文件中添加三方库依赖哟 ~

fluttertoast: ^8.0.9
    1. 细心的同学已经发现了,小编在 main方法中,app 的根节点使用了两层 MaterialApp 进行嵌套,这是由于 Overlay 的添加特性造成的。

overlay 的 insert 操作最终会转换成 Stack 布局,而实际上 insert 添加的图层是在所提供context 对应节点的父级节点上进行操作。

demo对应树状结构图

因为这个特性,如果我们要使用一个全局的 context 用于操作 overlay ,那么就要求这个全局的 context 需要拥有一个父节点。

如果仅仅是使用全局context进行导航操作(跳转、dialog),则无需使用两层 MaterialApp 进行嵌套。

PS: 如果不想使用两层 MaterialApp,我们也可以把代码变成这样,只要保证 key 的上层拥有父节点即可

void main() {
  runApp(
    MaterialApp(
      home: MaterialApp(
        routes: {
          '/pageA': (BuildContext context) => PageA(),
        },
        home: Scaffold(
          key: NavigatorProvider.navigatorKey
          body: TestNavigatorWidget(),
        ),
      ),
    ),
  );
}
abstract class NavigatorProvider {
  static final GlobalKey _globalKey = GlobalKey();
  static GlobalKey get globalKey => _globalKey;
  static BuildContext? get context => _globalKey.currentContext;
  static NavigatorState get navigator =>
      Navigator.of(_globalKey.currentState!.context);
}