主要开发内容

  • 页面骨架搭建
  • 主页底部导航栏
  • 仅首次启动APP时展示权限授予页
  • 权限授予支持跳过

主页底部导航栏

最终样式预览:
主页底部导航栏
中部的按钮用于后续跳转到添加账单页面,加号按钮对整个导航栏还有一个挖槽的效果。
目前 APP 设计有四个页面,从左至右分别是:主页、数据统计页、AI 聊天页、设置页
点击底部导航栏的各项后将跳转到对应页面,同时在页面上左右滑动也可切换页面(参考微信的交互方式)。
底部导航栏切换选中项时将展示动画,该动画是 bounceInOut 曲线的缩放动画。

挖槽实现

首先是中部加号按钮的挖槽效果实现,利用了 Scaffold 组件的 floatingActionButtonLocation 属性,和 BottomAppBar 的 shape 属性共同实现:

// app_frame_page.dart

Widget build(BuildContext context) {
  return Scaffold(
    // 省略...
    floatingActionButton: FloatingActionButton(
      // 省略..
    ),
    // centerDocked 将 floatingButton 置于中央靠下的位置
    floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
    bottomNavigationBar: MyBottomAppBar(
      // 省略
    ),
  );
}

// my_bottom_app_bar.dart

Widget build(BuildContext context) {
  return BottomAppBar(
    shape: CircularNotchedRectangle(),
    // 控制挖槽边缘离 floatingButton 边缘的距离
    notchMargin: 10,
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceAround,
      children: _buildChildren(context),
    ),
  );
}

底部导航栏项切换以及动画实现

由于需要同时支持手指左右滑动切换页面,所以我们需要把状态提到父组件进行管理,MyBottomAppBar 应该设计成一个 StatelessWidget,通过父组件改变传入的当前选中项索引来更新自己,并向父组件提供一个回调函数,让父组件决定点击导航栏项的动作。
实现代码如下:

class MyBottomAppBar extends StatelessWidget {
  const MyBottomAppBar({
    super.key,
    required this.selectedIndex,
    required this.bottomBarItems,
    required this.onPressed,
  });

  final int selectedIndex;
  final List<MyBottomBarItem> bottomBarItems;
  final Function(int index) onPressed;

  Widget _buildIconButton({
    required BuildContext context,
    required int index,
    required IconData icon,
    IconData? activeIcon,
  }) {
    final isSelected = selectedIndex == index;

    return IconButton(
      onPressed: () {
        onPressed(index);
      },
      icon: AnimatedSwitcher(
        duration: Duration(milliseconds: 200),
        transitionBuilder: (child, animation) => ScaleTransition(scale: CurvedAnimation(parent: animation, curve: Curves.bounceInOut), child: child),
        child: Icon(
          isSelected ? (activeIcon ?? icon) : icon,
          key: ValueKey<int>(isSelected ? 1 : 0),
          color: isSelected
              ? Theme.of(context).colorScheme.primary
              : Colors.grey,
          size: 30,
        ),
      ),
    );
  }
  // 省略...
}

class MyBottomBarItem {
  const MyBottomBarItem({required this.icon, this.activeIcon});

  final IconData icon;
  final IconData? activeIcon;
}

除了 MyBottomAppBar 组件本身,我们还定义了 MyBottomBarItem 类,来承载每个导航栏项的有关配置,例如图标和选中时的图标。
在MyBottomAppBar的构造函数中,我们定义了三个参数:

  • selectedIndex:当前选中项的索引,该值由父组件管理
  • bottomBarItems:导航栏项的配置,注意该列表的长度与最终显示出来的导航栏项的个数是相同的
  • onPressed:点击导航栏项后的回调,该回调将传入被点击的导航栏项的索引供父组件处理

其中_buildIconButton方法是关键,我们构建了一个 IconButton,并在该 Button 被点击时调用父组件传入的回调函数,并且还使用了 AnimatedSwitcher 来切换选中和未选中的图标风格,注意要给 Icon 组件不同选中状态下指定不同的 key,这是因为 AnimatedSwitcher 是以 key 的不同为依据来判断是否执行动画的,如果 key 没有或者不变,那么将不会执行动画,
AnimatedSwitcher 的动画默认是 Fading 样式,也就是透明度渐变,不符合我们缩放动画的要求,我们可以通过其 transitionBuilder 参数来自定义动画,该参数接受一个回调函数,我们可以在回调函数中返回自己的动画类型,并自定义一些动画的参数,例如动画曲线、速度、值的变化范围等等。该回调函数的参数 animation 是 Animation<double> 类型,一个普通的 double 值变化,我们可以将其映射成其他类型(使用 Tween,例如 ColorTween,可以映射成颜色值变化,但是这里我们不需要),以及修改动画曲线(使用 CurvedAnimation 类)

页面切换实现以及自定义底部导航栏的使用

写好底部导航栏后,就可以开始实现页面的切换了,考虑到页面切换我们也需要有动画,以及要和底部导航栏的状态进行联动,我们采用 PageView 来实现,我们可以通过实例化一个 PageController 并传入 PageView,来控制 PageView 的翻页,以及通过 PageView 提供的 onPageChanged 回调函数来更新底部导航栏的状态,关键代码实现:

class _AppFramePageState extends State<AppFramePage> {
  final _pageController = PageController();
  int _currentIndex = 0;
  final List<Widget> _pages = [
    Placeholder(),
    Placeholder(),
    Placeholder(),
    Placeholder(),
  ];

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("记账")),
      body: PageView(
        controller: _pageController,
        onPageChanged: (index) {
          setState(() {
            _currentIndex = index;
          });
        },
        children: _pages,
      ),
      // 省略...
      bottomNavigationBar: MyBottomAppBar(
        selectedIndex: _currentIndex,
        bottomBarItems: [
          MyBottomBarItem(
            icon: Icons.home_outlined,
            activeIcon: Icons.home_filled,
          ),
          MyBottomBarItem(
            icon: Icons.pie_chart_outline,
            activeIcon: Icons.pie_chart,
          ),
          MyBottomBarItem(
            icon: Icons.chat_bubble_outline,
            activeIcon: Icons.chat_bubble_outlined,
          ),
          MyBottomBarItem(
            icon: Icons.settings_outlined,
            activeIcon: Icons.settings,
          ),
        ],
        onPressed: (index) {
          _pageController.animateToPage(
            index,
            duration: Duration(milliseconds: 200),
            curve: Curves.easeInOut,
          );
        },
      ),
    );
  }
}

由于具体的页面还未实现,所以在_page 列表中,我们创建了四个 Placeholder 来占位,将_pages 列表和_pageController 传入PageView 中,并传入一个回调函数,当页码有更新时,同步更新我们维护的_currentIndex 状态,并用 setState 函数进行 UI 更新。
在导航栏的使用上,传入导航栏项不必多说,主要是 onPressed 回调函数的代码,我们使用_pageController 的 animateTo 方法来跳转到指定索引的页面,并定义了跳转的动画曲线和动画时间。

权限授予页相关修改

记录首次启动 APP

我们采用 SharedPreferences 来记录 APP 是否是首次启动,启动 APP 时将从 SharedPreferences 中读取键为"firstLaunch"的值,如果有该值,那么说明 APP 不是首次启动,如果没有该值,说明 APP 是首次启动,在首次启动的情况下,将展示权限授予页面。要注意的是,当 APP 的本地数据被用户清空时,也将算作首次启动。
为了方便同步磁盘数据和内存数据以及控制 UI 更新,考虑使用 GetxController 来实现数据同步和 UI 控制。实现代码如下:

class AppSettingsController extends GetxController {
  Completer<void>? _loadingCompleter;
  Future? get loadingFuture => _loadingCompleter?.future;

  final firstLaunch = true.obs;


  Future<void> _loadSettingsFromDisk() async {
    firstLaunch.value = StoreUtils.pref.getBool(StoreKeys.FIRST_LAUNCH) ?? true;
  }

  /// 将当前设置项的值保存到本地
  Future<void> saveToDisk() async {
    await StoreUtils.pref.setBool(StoreKeys.FIRST_LAUNCH, firstLaunch.value);
  }

  
  void onInit() {
    super.onInit();
    _loadingCompleter = Completer();
    _loadSettingsFromDisk().then((_) {
      _loadingCompleter?.complete();
      _loadingCompleter = null;
    });
  }
}

我将该类命名为 AppSettingsController,后续应用的一些设置也可以通过这个类来进行管理,不仅是首次启动这个标志数据。
由于从本地读取数据的是异步行为,为了实现在读取完本地数据后再展示 UI 界面,我们这里使用了 Flutter 中非常方便的一个类 Completer,该类可以让我们自己控制 Future 的完成时机,当开始加载数据时,创建一个 Completer 并将其保存起来,在其他地方就可以通过 await _loadingCompleter.future 来等待该 Future 完成,完成的时机是在_loadSettingsFromDisk函数的 then 回调中,而此时磁盘数据已经全部加载完毕。同时也需要考虑 API 安全性,我将_loadingCompleter设计为了私有,通过 loadingFuture 方法暴露其 Future 对象,防止外部修改。
接下来只需要在 main 函数中,启动 APP 前先 await loadingFuture,等待数据从磁盘加载完毕后再启动 UI 界面:

Future<void> main() async {
  // 省略无关代码...
  // 加载 APP 设置
  final appSettingsController = Get.put(AppSettingsController());
  // 等待加载完毕
  await appSettingsController.loadingFuture;
  runApp(const MyApp());
}

除此之外,还实现了几个用来访问本地磁盘数据的工具类,这个比较简单就不赘述了

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐