上一篇完成了项目的基础架构搭建,这篇来实现应用的主框架和底部导航。一个好的导航设计能让用户快速找到想要的功能,理财类应用通常采用底部 Tab 导航,把最常用的功能放在一级入口。
请添加图片描述

导航方案选型

Flutter 自带的 BottomNavigationBar 功能够用,但样式比较普通。这次选用 convex_bottom_bar 这个库,它支持中间凸起的按钮效果,视觉上更有层次感,也更符合现代应用的设计趋势。

选择第三方库时要考虑几个因素:功能是否满足需求、维护是否活跃、文档是否完善、社区评价如何。convex_bottom_bar 在这几个方面都表现不错,是个可靠的选择。

底部导航需要承载四个主要模块:首页、统计、预算、我的。中间再放一个悬浮的添加按钮,方便用户快速记账。这种布局在记账类应用中很常见,用户已经形成了使用习惯。

导航设计要遵循用户的心智模型。用户对记账应用有一定的预期,比如首页看总览、统计看分析、预算看控制、我的看设置。按照这个预期来设计,用户上手会更快。

四个 Tab 的选择是经过深思熟虑的。首页是用户最常访问的页面,放在第一位。统计和预算是核心功能,放在中间。我的页面访问频率较低,放在最后。

主框架页面结构

MainPage 是整个应用的容器,负责管理底部导航和页面切换。先看整体结构:

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:convex_bottom_bar/convex_bottom_bar.dart';
import '../home/home_page.dart';
import '../statistics/statistics_page.dart';
import '../budget/budget_page.dart';
import '../profile/profile_page.dart';
import '../../routes/app_pages.dart';
import 'main_controller.dart';

const _primaryColor = Color(0xFF2E7D32);

导入部分包含了 Flutter 核心库、GetX、convex_bottom_bar,以及四个子页面和路由配置。导入语句的组织要有规律,通常按照:Dart 核心库、Flutter 库、第三方库、项目内部文件的顺序排列。

颜色定义放在文件顶部用 const 声明,这样做有两个好处:一是避免在 build 方法中重复创建 Color 对象,二是方便统一管理和修改。_primaryColor 用下划线前缀表示私有,只在当前文件使用。

常量的使用是性能优化的基础。const 对象在编译时就确定了,运行时不需要重新创建。在 Flutter 中,尽可能使用 const 可以减少不必要的对象创建和垃圾回收。

页面类的定义:

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

  
  Widget build(BuildContext context) {
    final controller = Get.put(MainController());
    final pages = [
      const HomePage(), 
      const StatisticsPage(), 
      const BudgetPage(), 
      const ProfilePage()
    ];

MainPage 继承自 StatelessWidget,因为状态由 GetX Controller 管理,页面本身不需要持有状态。这是 GetX 推荐的做法,让 Widget 保持纯净。

Get.put 在 build 方法中调用,GetX 会自动处理重复注册的情况。如果 Controller 已存在就返回现有实例,不存在才创建新的。这种写法简洁,不需要在 Binding 中提前注册。

pages 数组定义了四个子页面,都用 const 修饰。这些页面在整个应用生命周期内只创建一次,切换 Tab 时不会重新创建。

页面数组的顺序要和底部导航的顺序一致。索引 0 对应首页,索引 1 对应统计,以此类推。如果顺序不一致,会导致点击 Tab 时显示错误的页面。

Scaffold 结构

Scaffold 是 Flutter 页面的基础结构:

    return Scaffold(
      body: Obx(() => IndexedStack(
        index: controller.currentIndex.value, 
        children: pages
      )),
      bottomNavigationBar: Obx(() => ConvexAppBar(
        style: TabStyle.react,
        backgroundColor: _primaryColor,
        activeColor: Colors.white,
        color: Colors.white70,
        items: const [
          TabItem(icon: Icons.home, title: '首页'),
          TabItem(icon: Icons.pie_chart, title: '统计'),
          TabItem(icon: Icons.account_balance_wallet, title: '预算'),
          TabItem(icon: Icons.person, title: '我的'),
        ],
        initialActiveIndex: controller.currentIndex.value,
        onTap: controller.changePage,
      )),

Scaffold 提供了应用页面的基本结构,包括 body、appBar、bottomNavigationBar、floatingActionButton 等。这里只用了 body、bottomNavigationBar 和 floatingActionButton。

body 部分用 IndexedStack 包裹四个子页面,通过 index 控制显示哪个页面。Obx 包裹 IndexedStack,当 currentIndex 变化时自动更新显示。

bottomNavigationBar 使用 ConvexAppBar,配置了样式、颜色、图标等属性。同样用 Obx 包裹,保证状态同步。

悬浮按钮的配置:

      floatingActionButton: FloatingActionButton(
        onPressed: () => Get.toNamed(Routes.addTransaction),
        backgroundColor: _primaryColor,
        child: const Icon(Icons.add, color: Colors.white),
      ),
    );
  }
}

FloatingActionButton 是 Material Design 的标志性组件,用于最重要的操作。在理财应用中,添加交易是最高频的操作,放在悬浮按钮上非常合适。

点击悬浮按钮后,通过 Get.toNamed 跳转到添加交易页面。命名路由的好处是解耦,页面之间不需要直接引用,只需要知道路由名称。

悬浮按钮的位置默认在右下角,正好在底部导航栏上方。这个位置用户容易触及,也不会遮挡重要内容。

IndexedStack 的妙用

页面切换用 IndexedStack 而不是 PageView,这是个重要的选择:

body: Obx(() => IndexedStack(
  index: controller.currentIndex.value, 
  children: pages
)),

IndexedStack 会同时保持所有子页面的状态,切换时不会重建页面。对于理财应用来说,用户可能在首页看了数据,切到统计页面分析,再切回首页,数据应该还在,不需要重新加载。

这种行为和 PageView 不同。PageView 默认只保持相邻页面的状态,其他页面会被销毁。虽然可以通过 AutomaticKeepAliveClientMixin 来保持状态,但 IndexedStack 更简单直接。

IndexedStack 的缺点是所有子页面都会被创建,即使用户从未访问过。对于只有四个页面的应用,这个开销可以接受。如果页面数量很多,需要考虑懒加载策略。

Obx 包裹 IndexedStack,当 currentIndex 变化时只重建这一小块,而不是整个 Scaffold。这是 GetX 响应式更新的精髓,只更新需要更新的部分。

pages 数组在 build 方法开头定义,每次 build 都会创建新的数组,但里面的 Widget 是 const 的,实际上不会重复创建。这是 Dart 的常量优化机制。

如果页面数量很多或者某些页面初始化开销大,可以考虑懒加载:

final pages = [
  const HomePage(),
  controller.currentIndex.value >= 1 
    ? const StatisticsPage() 
    : const SizedBox(),
  controller.currentIndex.value >= 2 
    ? const BudgetPage() 
    : const SizedBox(),
  controller.currentIndex.value >= 3 
    ? const ProfilePage() 
    : const SizedBox(),
];

这种方式只有在用户访问过某个 Tab 后才会创建对应的页面。SizedBox 是一个轻量级的占位组件,几乎没有开销。

不过对于只有四个页面的应用,这种优化意义不大,反而增加了复杂度。过早优化是万恶之源,应该在确实遇到性能问题时再考虑优化。

ConvexAppBar 配置详解

convex_bottom_bar 提供了多种样式,这里选用 TabStyle.react:

bottomNavigationBar: Obx(() => ConvexAppBar(
  style: TabStyle.react,
  backgroundColor: _primaryColor,
  activeColor: Colors.white,
  color: Colors.white70,
  items: const [
    TabItem(icon: Icons.home, title: '首页'),
    TabItem(icon: Icons.pie_chart, title: '统计'),
    TabItem(icon: Icons.account_balance_wallet, title: '预算'),
    TabItem(icon: Icons.person, title: '我的'),
  ],
  initialActiveIndex: controller.currentIndex.value,
  onTap: controller.changePage,
)),

TabStyle.react 是一种响应式风格,点击时有轻微的缩放动画,交互感比较好。动画让界面更生动,也给用户即时的反馈。

其他可选的样式包括:TabStyle.fixed 是固定样式,没有动画;TabStyle.fixedCircle 中间按钮是圆形凸起;TabStyle.flip 是翻转动画效果;TabStyle.textIn 文字从下方滑入。

backgroundColor 设置导航栏背景色,和 AppBar 保持一致,形成统一的视觉风格。activeColor 是选中状态的颜色,color 是未选中状态的颜色,用 white70 稍微透明一点,形成对比。

颜色的对比度很重要。选中和未选中状态要有明显区分,让用户一眼就能看出当前在哪个 Tab。但对比也不能太强烈,否则会显得刺眼。

items 数组定义每个 Tab 的图标和文字:

items: const [
  TabItem(icon: Icons.home, title: '首页'),
  TabItem(icon: Icons.pie_chart, title: '统计'),
  TabItem(icon: Icons.account_balance_wallet, title: '预算'),
  TabItem(icon: Icons.person, title: '我的'),
],

图标选择要有辨识度:home 代表首页,pie_chart 代表统计图表,account_balance_wallet 代表钱包/预算,person 代表个人中心。这些都是用户熟悉的隐喻,不需要额外解释。

图标和文字要配合使用。图标提供快速识别,文字提供明确含义。只有图标可能会让用户困惑,只有文字又不够直观。

initialActiveIndex 设置初始选中的 Tab,这里绑定到 controller 的状态。onTap 是点击回调,调用 controller.changePage 切换页面。

回调函数的设计要简洁。onTap 只负责通知 Controller,具体的逻辑在 Controller 中处理。这种分离让代码更清晰,也更容易测试。

悬浮添加按钮

记账是高频操作,把添加按钮放在显眼的位置很重要:

floatingActionButton: FloatingActionButton(
  onPressed: () => Get.toNamed(Routes.addTransaction),
  backgroundColor: _primaryColor,
  child: const Icon(Icons.add, color: Colors.white),
),

FloatingActionButton 默认会悬浮在右下角,正好在底部导航栏上方。这个位置符合人体工程学,用户的拇指容易触及。

点击后跳转到添加交易页面,用 Get.toNamed 进行命名路由导航。导航完成后,用户可以通过返回按钮回到主页面。

有些应用会把添加按钮放在底部导航栏中间,做成凸起的效果。convex_bottom_bar 也支持这种布局,但需要处理点击事件的特殊逻辑。这里选择分开放置,实现更简单,功能上也没有区别。

悬浮按钮的设计要考虑可见性和可触及性。按钮要足够大,颜色要醒目,位置要方便点击。同时也要注意不要遮挡重要内容。

控制器实现

MainController 非常简单,只需要管理当前选中的 Tab 索引:

import 'package:get/get.dart';

class MainController extends GetxController {
  final RxInt currentIndex = 0.obs;

  void changePage(int index) {
    currentIndex.value = index;
  }
}

currentIndex 用 RxInt 包装,初始值为 0 表示默认显示首页。RxInt 是 GetX 的响应式整数类型,值变化时会自动通知监听者。

changePage 方法在点击 Tab 时调用,更新索引值。方法实现非常简单,就是赋值操作。但通过方法封装,可以在后续添加额外逻辑,比如埋点统计。

这个 Controller 看起来简单到没必要单独抽出来,但这样做有几个好处:保持 View 层的纯净,所有状态都在 Controller 中;方便后续扩展,比如添加页面切换的埋点统计;可以在其他地方通过 Get.find 获取并控制页面切换。

简单的 Controller 也是 Controller。不要因为逻辑简单就把状态放在 Widget 中,保持架构的一致性更重要。

比如在添加交易成功后,可能想自动切换到首页查看最新记录:

final mainController = Get.find<MainController>();
mainController.changePage(0);

这种跨页面的状态控制,只有把状态放在 Controller 中才能实现。如果状态在 Widget 中,其他页面无法访问。

Binding 配置

MainBinding 用于注册 MainController:

import 'package:get/get.dart';
import 'main_controller.dart';

class MainBinding extends Bindings {
  
  void dependencies() {
    Get.lazyPut<MainController>(() => MainController());
  }
}

lazyPut 是懒加载注册,只有在第一次 Get.find 时才会创建实例。对于主框架这种应用启动就需要的页面,用 put 或 lazyPut 区别不大。

但养成用 lazyPut 的习惯是好的,可以减少启动时的初始化开销。特别是对于那些可能不会被访问的页面,懒加载可以显著提升启动速度。

在路由配置中关联 Binding:

GetPage(
  name: Routes.main, 
  page: () => const MainPage(), 
  binding: MainBinding()
),

这样当导航到 /main 路由时,GetX 会自动执行 MainBinding 的 dependencies 方法,注册所需的依赖。这种声明式的依赖注入让代码更清晰。

Binding 和路由的关联是 GetX 的特色功能。它让依赖的生命周期和页面的生命周期绑定,页面销毁时依赖也会被清理,避免内存泄漏。

页面切换动画

默认情况下 IndexedStack 切换是没有动画的,页面直接替换。如果想要添加淡入淡出效果,可以用 AnimatedSwitcher:

body: Obx(() => AnimatedSwitcher(
  duration: const Duration(milliseconds: 200),
  child: pages[controller.currentIndex.value],
)),

AnimatedSwitcher 会在子组件变化时自动播放动画。duration 设置动画时长,200 毫秒是比较舒适的时长,不会太快也不会太慢。

但这样做会失去 IndexedStack 保持状态的优势,每次切换都会重建页面。这是一个权衡:要动画效果还是要状态保持。

折中的方案是给每个页面加上 key:

body: Obx(() => AnimatedSwitcher(
  duration: const Duration(milliseconds: 200),
  child: KeyedSubtree(
    key: ValueKey(controller.currentIndex.value),
    child: pages[controller.currentIndex.value],
  ),
)),

KeyedSubtree 可以给子组件指定 key,AnimatedSwitcher 根据 key 的变化来判断是否需要播放动画。但这仍然不能保持页面状态。

不过对于底部导航这种高频切换的场景,我倾向于不加动画,响应更快,用户体验更好。动画虽然好看,但如果影响了响应速度,得不偿失。

处理返回键

在 Android 上,用户按返回键时的预期行为是:如果不在首页,先切回首页;如果已经在首页,再按一次退出应用。实现这个逻辑需要用 WillPopScope:

return WillPopScope(
  onWillPop: () async {
    if (controller.currentIndex.value != 0) {
      controller.changePage(0);
      return false;
    }
    return true;
  },
  child: Scaffold(
    // ...
  ),
);

onWillPop 返回 false 表示拦截返回事件,返回 true 表示允许返回(退出应用)。这种处理方式符合用户的直觉,避免误触退出。

WillPopScope 是 Flutter 提供的返回键拦截组件。它包裹在需要拦截返回键的组件外面,当用户按返回键时会调用 onWillPop 回调。

更友好的做法是双击退出,给用户一个确认的机会:

DateTime? _lastPressedAt;

onWillPop: () async {
  if (controller.currentIndex.value != 0) {
    controller.changePage(0);
    return false;
  }
  if (_lastPressedAt == null || 
      DateTime.now().difference(_lastPressedAt!) > 
        const Duration(seconds: 2)) {
    _lastPressedAt = DateTime.now();
    Get.snackbar('提示', '再按一次退出应用');
    return false;
  }
  return true;
},

这段代码的逻辑是:如果两次按返回键的间隔超过 2 秒,就显示提示并重置计时;如果间隔在 2 秒内,就允许退出。这种设计可以有效防止误触退出。

Get.snackbar 是 GetX 提供的便捷方法,可以快速显示一个 Snackbar 提示。不需要 BuildContext,在任何地方都可以调用。

底部导航栏高度适配

在有底部安全区域的设备上(比如 iPhone X 及以后的机型),需要注意导航栏的高度适配:

bottomNavigationBar: SafeArea(
  child: ConvexAppBar(
    height: 50,
    // ...
  ),
),

SafeArea 会自动处理安全区域的内边距,避免内容被刘海或底部横条遮挡。convex_bottom_bar 默认会处理这个问题,但如果你自定义了高度,要记得加上安全区域。

或者使用 MediaQuery 获取底部安全区域高度:

final bottomPadding = MediaQuery.of(context).padding.bottom;

MediaQuery 提供了设备的各种信息,包括屏幕尺寸、像素密度、安全区域等。在需要精确控制布局时,这些信息非常有用。

安全区域的处理是跨平台开发的重要课题。不同设备的安全区域大小不同,有的设备有刘海,有的有底部横条,有的两者都有。正确处理安全区域可以让应用在各种设备上都有良好的显示效果。

导航栏显示隐藏

某些页面可能需要隐藏底部导航栏,比如全屏查看图表。可以在 Controller 中添加一个控制变量:

class MainController extends GetxController {
  final RxInt currentIndex = 0.obs;
  final RxBool showBottomNav = true.obs;

  void changePage(int index) {
    currentIndex.value = index;
  }

  void hideBottomNav() => showBottomNav.value = false;
  void showBottomNavBar() => showBottomNav.value = true;
}

showBottomNav 控制导航栏的显示状态。hideBottomNav 和 showBottomNavBar 方法分别用于隐藏和显示导航栏。

然后在 UI 中根据这个变量决定是否显示:

bottomNavigationBar: Obx(() => controller.showBottomNav.value 
  ? ConvexAppBar(...)
  : const SizedBox.shrink()
),

SizedBox.shrink 是一个零尺寸的组件,用于占位但不显示任何内容。当 showBottomNav 为 false 时,导航栏会消失,页面内容会占据整个屏幕。

这种设计让导航栏的显示隐藏变得可控。子页面可以通过 Get.find 获取 MainController,然后调用相应的方法来控制导航栏。

徽章显示

有时候需要在 Tab 上显示徽章,比如未读消息数量:

items: [
  TabItem(icon: Icons.home, title: '首页'),
  TabItem(icon: Icons.pie_chart, title: '统计'),
  TabItem(icon: Icons.account_balance_wallet, title: '预算'),
  TabItem(
    icon: Stack(
      children: [
        const Icon(Icons.person),
        Positioned(
          right: 0,
          top: 0,
          child: Container(
            padding: const EdgeInsets.all(2),
            decoration: const BoxDecoration(
              color: Colors.red,
              shape: BoxShape.circle,
            ),
            child: const Text('3', 
              style: TextStyle(fontSize: 10, color: Colors.white)),
          ),
        ),
      ],
    ), 
    title: '我的'
  ),
],

使用 Stack 在图标上叠加一个红色圆点,显示数字。这种徽章设计在很多应用中都能看到,用户已经习惯了这种提示方式。

徽章的数字应该来自 Controller 的状态,当有新消息时更新数字,消息已读后清除徽章。这需要和后端或本地存储配合实现。

键盘弹出时的处理

当键盘弹出时,底部导航栏默认会被顶上去。如果不想要这个行为,可以设置 Scaffold 的 resizeToAvoidBottomInset:

return Scaffold(
  resizeToAvoidBottomInset: false,
  body: ...,
  bottomNavigationBar: ...,
);

设为 false 后,键盘弹出时页面不会调整大小,底部导航栏会被键盘遮挡。这在某些场景下是需要的,比如聊天页面。

但对于理财应用,通常希望键盘弹出时能看到输入框,所以保持默认值 true 就好。

键盘处理是移动端开发的常见问题。不同的页面可能需要不同的处理方式,要根据具体场景来决定。

页面生命周期

GetX Controller 提供了完整的生命周期回调:

class MainController extends GetxController {
  
  void onInit() {
    super.onInit();
    // 初始化逻辑
  }

  
  void onReady() {
    super.onReady();
    // 页面渲染完成后的逻辑
  }

  
  void onClose() {
    super.onClose();
    // 清理逻辑
  }
}

onInit 在 Controller 创建后立即调用,适合做初始化工作。onReady 在页面渲染完成后调用,适合做需要 BuildContext 的操作。onClose 在 Controller 销毁前调用,适合做清理工作。

生命周期的正确使用对于资源管理很重要。在 onInit 中创建的资源,应该在 onClose 中释放。比如定时器、流订阅等,不释放会导致内存泄漏。

状态持久化

如果希望用户下次打开应用时还在上次的 Tab,可以把 currentIndex 持久化:

class MainController extends GetxController {
  final RxInt currentIndex = 0.obs;
  final _storage = Get.find<StorageService>();

  
  void onInit() {
    super.onInit();
    currentIndex.value = _storage.lastTabIndex;
  }

  void changePage(int index) {
    currentIndex.value = index;
    _storage.saveLastTabIndex(index);
  }
}

在 onInit 中读取上次保存的索引,在 changePage 中保存当前索引。这样用户的使用习惯会被记住。

状态持久化是提升用户体验的小细节。用户不需要每次都从首页开始,可以直接回到上次离开的地方。

深色模式适配

底部导航栏需要适配深色模式:

bottomNavigationBar: Obx(() => ConvexAppBar(
  style: TabStyle.react,
  backgroundColor: Theme.of(context).brightness == Brightness.dark
    ? const Color(0xFF1E1E1E)
    : _primaryColor,
  activeColor: Colors.white,
  color: Colors.white70,
  // ...
)),

通过 Theme.of(context).brightness 判断当前是否为深色模式,然后使用不同的背景色。深色模式下使用深灰色背景,浅色模式下使用主题色背景。

深色模式的适配要全面。不仅是导航栏,所有组件都需要考虑深色模式下的显示效果。使用 Theme 可以让适配更加系统化。

无障碍支持

为了让视障用户也能使用应用,需要添加无障碍支持:

TabItem(
  icon: Icons.home, 
  title: '首页',
),

convex_bottom_bar 的 TabItem 会自动使用 title 作为无障碍标签。屏幕阅读器会朗读"首页",让视障用户知道这是什么功能。

如果需要更详细的描述,可以使用 Semantics 组件包裹:

Semantics(
  label: '首页,查看资产总览和最近交易',
  child: TabItem(icon: Icons.home, title: '首页'),
)

无障碍支持是应用质量的重要指标。让更多人能够使用应用,是开发者的社会责任。

测试考虑

主框架的测试主要关注页面切换逻辑:

void main() {
  test('changePage should update currentIndex', () {
    final controller = MainController();
    
    controller.changePage(2);
    
    expect(controller.currentIndex.value, 2);
  });
}

这个测试验证 changePage 方法能正确更新 currentIndex。测试要覆盖正常情况和边界情况,比如索引为 0 或最大值时的行为。

Widget 测试可以验证 UI 的正确性:

testWidgets('MainPage should show correct tab', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: MainPage()));
  
  expect(find.text('首页'), findsOneWidget);
  
  await tester.tap(find.text('统计'));
  await tester.pump();
  
  // 验证页面切换
});

测试是保证代码质量的重要手段。主框架作为应用的核心组件,更需要充分的测试覆盖。

性能优化

主框架的性能优化主要关注以下几点:

  1. 使用 const 构造函数减少对象创建
  2. 使用 IndexedStack 保持页面状态,避免重复创建
  3. Obx 精确包裹需要更新的组件,减少重建范围
  4. 避免在 build 方法中做耗时操作
// 好的做法
final pages = const [
  HomePage(), 
  StatisticsPage(), 
  BudgetPage(), 
  ProfilePage()
];

// 不好的做法
final pages = [
  HomePage(), // 每次 build 都会创建新实例
  StatisticsPage(),
  BudgetPage(),
  ProfilePage()
];

const 的使用看起来是小事,但积少成多,对性能的影响是显著的。养成使用 const 的习惯,是 Flutter 开发的基本功。

性能优化要有数据支撑。使用 Flutter DevTools 可以分析应用的性能,找出瓶颈所在。不要凭感觉优化,要用数据说话。

小结

主框架的实现看起来简单,但细节不少。核心要点包括:

用 IndexedStack 保持页面状态,避免切换时重建。这是底部导航的最佳实践,用户体验更好。

ConvexAppBar 提供美观的底部导航效果。选择合适的样式和颜色,让导航栏和整体设计风格一致。

悬浮按钮放在显眼位置,方便快速记账。添加交易是最高频的操作,要让用户触手可及。

Controller 管理页面索引,保持 View 层纯净。状态和 UI 分离,代码更清晰,也更容易测试。

处理返回键逻辑,提升用户体验。符合用户预期的行为,让应用更易用。

这套框架搭好之后,后续只需要专注于各个子页面的实现。下一篇会介绍首页的设计和实现,包括资产总览、月度统计、快捷功能入口等内容。

主框架是应用的骨架,它的设计决定了整个应用的结构。花时间把主框架做好,后续的开发会更加顺畅。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

多语言适配

底部导航的文字需要支持多语言:

items: [
  TabItem(icon: Icons.home, title: 'home'.tr),
  TabItem(icon: Icons.pie_chart, title: 'statistics'.tr),
  TabItem(icon: Icons.account_balance_wallet, title: 'budget'.tr),
  TabItem(icon: Icons.person, title: 'profile'.tr),
],

使用 GetX 的 .tr 扩展方法获取翻译文本。当语言切换时,导航栏的文字会自动更新。

多语言支持是应用国际化的基础。即使当前只支持中文,也建议从一开始就做好多语言架构,后续添加其他语言会更容易。

翻译文本要简洁。导航栏空间有限,文字太长会显示不全或换行,影响美观。

横屏适配

在平板或横屏模式下,底部导航可能需要调整:


Widget build(BuildContext context) {
  final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
  
  if (isLandscape) {
    return Row(
      children: [
        NavigationRail(
          selectedIndex: controller.currentIndex.value,
          onDestinationSelected: controller.changePage,
          destinations: const [
            NavigationRailDestination(icon: Icon(Icons.home), label: Text('首页')),
            NavigationRailDestination(icon: Icon(Icons.pie_chart), label: Text('统计')),
            NavigationRailDestination(icon: Icon(Icons.account_balance_wallet), label: Text('预算')),
            NavigationRailDestination(icon: Icon(Icons.person), label: Text('我的')),
          ],
        ),
        Expanded(child: pages[controller.currentIndex.value]),
      ],
    );
  }
  
  return Scaffold(...);
}

横屏时使用 NavigationRail 侧边导航,更好地利用屏幕空间。NavigationRail 是 Material Design 推荐的横屏导航方案。

响应式布局是跨平台开发的重要课题。同一套代码要在手机、平板、桌面等不同设备上都有良好的体验。

MediaQuery 提供了设备的各种信息,可以根据这些信息调整布局。除了方向,还可以根据屏幕宽度来决定使用哪种布局。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐