23 应用界面整合

1. 界面整合的需求分析

如下所示,在应用的底部添加导航栏,进行界面间的切换操作。下面从数据和界面的角度对该进行分析:

———————————————————— ————————————————————
113.gif 114.gif

当前界面中需要添加的数据有:

  • 底部栏的文字、图标资源列表
  • 底部栏的激活索引

在点击底部栏的按钮时,需要更新激活索引,并进行界面的重新构建。这里定义一个 MenuData 类,用于维护标签和图标数据:

class MenuData {
  // 标签
  final String label;

  // 图标数据
  final IconData icon;

  const MenuData({
    required this.label,
    required this.icon,
  });
}

对于界面构建逻辑来说,这是一个上下结构,上面是内容区域,下面是底部导航栏。所以,可以通过 Column 组件上下排列,其中内容区域通过 Expanded 组件进行延展,内容组件根据激活的索引值构建不同的界面。

image.png


2. 代码实现:第一版

Flutter 中提供了 BottomNavigationBar 组件可以展示底部栏,这里单独封装一个 AppBottomBar 组件用于维护底部栏的界面构建逻辑。其中需要传入激活索引、点击回调、菜单数据列表:

class AppBottomBar extends StatelessWidget {
  final int currentIndex;
  final List<MenuData> menus;
  final ValueChanged<int>? onItemTap;

  const AppBottomBar({
    Key? key,
    this.onItemTap,
    this.currentIndex = 0,
    required this.menus,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      backgroundColor: Colors.white,
      onTap: onItemTap,
      currentIndex: currentIndex,
      elevation: 3,
      type: BottomNavigationBarType.fixed,
      iconSize: 22,
      selectedItemColor: Theme.of(context).primaryColor,
      selectedLabelStyle: const TextStyle(fontWeight: FontWeight.bold),
      showUnselectedLabels: true,
      showSelectedLabels: true,
      items: menus.map(_buildItemByMenuMeta).toList(),
    );
  }

  BottomNavigationBarItem _buildItemByMenuMeta(MenuData menu) {
    return BottomNavigationBarItem(
      label: menu.label,
      icon: Icon(menu.icon),
    );
  }
}

然后就是构建整体结构,这里创建一个 AppNavigation 组件来处理。由于激活索引数据需要在交互时改变,并重新构建界面,所以 AppNavigation 继承自 StatefulWidget,在状态类中处理界面构建和状态数据维护的逻辑。

class AppNavigation extends StatefulWidget {
  const AppNavigation({Key? key}) : super(key: key);

  @override
  State<AppNavigation> createState() => _AppNavigationState();
}

class _AppNavigationState extends State<AppNavigation> {
  int _index = 0;

  final List<MenuData> menus = const [
    MenuData(label: '猜数字', icon: Icons.question_mark),
    MenuData(label: '电子木鱼', icon: Icons.my_library_music_outlined),
    MenuData(label: '白板绘制', icon: Icons.palette_outlined),
  ];

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded( child: _buildContent(_index)),
        AppBottomBar(
          currentIndex: _index,
          onItemTap: _onChangePage,
          menus: menus,
        )
      ],
    );
  }

  void _onChangePage(int index) {
    setState(() {
      _index = index;
    });
  }

内容区域的构建使用 _buildContent 方法,根据不同的激活索引,返回创建不同的界面:

  • index = 0 时,构建猜数字界面;
  • index = 1 时,构建电子木鱼界面;
  • index = 2 时,构建白板绘制界面;
  Widget _buildContent(int index) {
    switch(index){
      case 0:
       return const GuessPage();
      case 1:
        return const MuyuPage();
      case 2:
        return const Paper();
      default:
        return const SizedBox.shrink();
    }
  }
}

到这里就完成了点击底部导航,切换界面的功能,当前代码位置: navigation 。 但这种方式处理会有一些问题:伴随着界面的消失,状态类会被销毁;下次再到该界面时会重新初始化状态类,如下所示:

在绘制面板绘制 切换后,状态重置
115.gif 116.gif

3. 状态类数据的保持

想要避免每次切换都会重置状态数据,大体上有三种解决方案:

  • 1.使用 AutomaticKeepAliveClientMixin 对状态类进行保活,这种方案只能用于可滑动组件中。这里可以使用 PageView 组件来实现切页并保活的效果。
  • 2.将状态数据提升到上层,比如将三个界面的状态数据都交由 _AppNavigationState 状态类维护。如果直接用这种方式,很容易造成一个超级大的类,来维护很多数据。其实状态管理工具,就是基于这种思路,将数据交由上层维护,同时提供了分模块处理数据的能力。
  • 3.保持数据的持久性,比如将数据保存到本地文件或数据库,每次初始化时进行加载复现。这种方式处理起来比较麻烦,初始化加载数据也需要一点时间。但这种方式在界面不可见时,可以释放内存中的数据。

这里使用 方式 1 来处理是最简单的。在 _buildContent 方法中返回 PageView 组件,并将三个内容界面作为 children 入参,通过 PageController 来控制界面的切换。注意一点:将 physics 设置设置为 NeverScrollableScrollPhysics 可以禁止 PageView 的滑动,如果想要运行滑动切页,可以去除。

---->[_AppNavigationState]----
final PageController _ctrl = PageController();

Widget _buildContent() {
  return PageView(
    physics: const NeverScrollableScrollPhysics(),
    controller: _ctrl,
    children: const [
       GuessPage(),
       MuyuPage(),
       Paper(),
    ],
  );
}

void _onChangePage(int index) {
  _ctrl.jumpToPage(index);
  setState(() {
    _index = index;
  });
}

另外如果期望某个状态类保活,需要让其混入 AutomaticKeepAliveClientMixin, 并覆写 wantKeepAlive 返回 true 。如下是对画板状态类的处理,其他两个同理:

image.png

在绘制面板绘制 切换后,状态保活
117.gif 118.gif

到这里,就将之前的三个小案例,集成到了一个应用中,并且在切换界面的过程中,可以保持状态数据不被重置。当前代码位置 navigation

上面可以保证程序运行期间,各界面状态类的保活,但是当应用关闭之后,内存中的数据会被清空。再次进入应用时还是无法恢复到之前的状态,想要记住用户的信息,就必须对数据进行持久化的存储。比如存储为本地文件、数据库、网络数据等,下一篇将介绍数据的持久化存储。


4. 优化一些缺陷

如下所示,左侧是 Column 组件上下排列,当键盘顶起之后,底部会留出一块空白,高为底部导航高度。想解决这个问题,使用 Scaffold 组件即可,它有一个 bottomNavigationBar 的插槽,不会被键盘顶起。

Column 结构 Scaffold 结构
image.png 42f13c95e59f1a9129353d2b3f22e37.jpg

这时,将 _AppNavigationState 的构建方法改为如下代码:

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     body: _buildContent(),
     bottomNavigationBar: AppBottomBar(
       currentIndex: _index,
       onItemTap: _onChangePage,
       menus: menus,
     ),
   );
 }

下面以 AppBar 的主题介绍一下 Flutter 默认配置的能力。项目中希望所有的 AppBar 都是白色背景、状态类透明、标题居中、图标颜色、文字颜色为黑色。

image.png

如果每次使用 AppBar 组件就配置一次,那代码书写将会非常复杂。Flutter 在主题数据的功能,只要指定主题,其下节点中的对应组件,就会默认使用的配置数据。如下所示,在 MaterialApp 的 theme 入参中可以配置主题数据:

image.png

这样,以前使用 AppBar 的地方就不用再配置那么多信息了。比如电子木鱼界面的 AppBar 就可以清爽多了:

image.png

这里只是拿 AppBarTheme 举个例子,还有其他很多的主题可以配置,大家可以在以后慢慢了解。


5. 本章小结

本章我们主要将之前的三个小案例整合到了一个项目中,通过底部导航栏 + PageView 实现界面间的切换。另外也就 State 的状态保活进行了简单地认识,这里只是程序运行期间,保证各界面状态类的活性,但是当应用关闭之后,内存中的数据会被清空。再次进入应用时还是无法恢复到之前的状态。

想要永久记住用户的信息,就必须对数据进行持久化的存储。比如存储为本地文件、数据库、网络数据等,在程序启动时进行加载,恢复状态数据。这是应用程序非常重要的一个部分,下一篇将介绍数据的持久化存储。