底部导航栏是移动应用中最常见的导航模式之一。它让用户可以在应用的主要功能区域之间快速切换。今天我们来详细讲解如何为数独游戏实现一个美观实用的底部导航栏,包括页面切换、状态保持等功能。
在设计底部导航栏之前,我们需要确定应用的主要功能模块。数独游戏通常包括:游戏主界面、统计页面、每日挑战、设置页面。这四个模块相对独立,适合使用底部导航栏进行组织。用户可以随时切换到任意模块,而不会丢失当前模块的状态。
让我们从创建MainPage组件开始。
dart
import 'package:flutter/material.dart';
import 'package:convex_bottom_bar/convex_bottom_bar.dart';
import 'game/game_page.dart';
import 'stats/stats_page.dart';
import 'daily/daily_page.dart';
import 'settings/settings_page.dart';
我们导入了Flutter的Material库和convex_bottom_bar包。convex_bottom_bar是一个流行的底部导航栏库,提供了多种美观的导航栏样式。同时导入四个子页面组件,它们将作为导航栏的内容页面。使用第三方库可以快速实现复杂的UI效果。
定义MainPage类。
dart
class MainPage extends StatefulWidget {
const MainPage({super.key});
@override
State<MainPage> createState() => _MainPageState();
}
MainPage使用StatefulWidget是因为需要管理当前选中的导航项索引。这个状态决定了显示哪个子页面。const构造函数让Flutter可以在重建时复用这个widget,提升性能。
状态类的定义。
dart
class _MainPageState extends State<MainPage> {
int _currentIndex = 0;
final List<Widget> _pages = [
const GamePage(),
const StatsPage(),
const DailyPage(),
const SettingsPage(),
];
_currentIndex记录当前选中的导航项,初始值0表示默认显示第一个页面(游戏页面)。_pages是一个包含所有子页面的列表,使用const构造函数创建页面实例。列表的顺序与导航栏图标的顺序一致,通过索引对应。
build方法构建整体布局。
dart
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _pages,
),
Scaffold提供了Material Design的基本页面结构。IndexedStack是关键组件,它同时持有所有子页面,但只显示index指定的那个。与PageView不同,IndexedStack会保持所有子页面的状态,切换页面时不会重建。这对于游戏页面特别重要,可以保持游戏进度。
配置底部导航栏。
dart
bottomNavigationBar: ConvexAppBar(
style: TabStyle.react,
backgroundColor: Colors.white,
activeColor: const Color(0xFF2196F3),
color: Colors.grey,
items: const [
TabItem(icon: Icons.grid_on, title: '游戏'),
TabItem(icon: Icons.bar_chart, title: '统计'),
TabItem(icon: Icons.calendar_today, title: '每日挑战'),
TabItem(icon: Icons.settings, title: '设置'),
],
ConvexAppBar是convex_bottom_bar库提供的导航栏组件。style设置为TabStyle.react,这种样式在选中时有一个凸起的动画效果。backgroundColor设置背景色为白色,activeColor设置选中项的颜色为蓝色,color设置未选中项的颜色为灰色。items定义了四个导航项,每个包含图标和标题。
处理导航项点击。
dart
initialActiveIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
),
);
}
}
initialActiveIndex设置初始选中项。onTap回调在用户点击导航项时触发,参数index是被点击项的索引。在回调中调用setState更新_currentIndex,这会触发build方法重新执行,IndexedStack会显示新选中的页面。这种响应式的设计让导航切换变得简单直观。
让我们深入理解IndexedStack的工作原理。
dart
// IndexedStack vs PageView
// IndexedStack: 所有子widget同时存在,只显示一个
IndexedStack(
index: currentIndex,
children: [Page1(), Page2(), Page3()],
)
// 优点:切换快速,状态保持
// 缺点:内存占用较高
// PageView: 可以滑动切换,支持懒加载
PageView(
controller: pageController,
children: [Page1(), Page2(), Page3()],
)
// 优点:支持手势滑动,可以懒加载
// 缺点:默认不保持状态,需要额外处理
IndexedStack适合页面数量较少且需要保持状态的场景。所有子页面在首次构建时就会创建,切换时只是改变显示哪个。PageView更适合内容较多或需要滑动切换的场景。对于数独游戏,IndexedStack是更好的选择,因为我们需要保持游戏进度。
如果不使用第三方库,可以用Flutter原生的BottomNavigationBar。
dart
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
type: BottomNavigationBarType.fixed,
selectedItemColor: Colors.blue,
unselectedItemColor: Colors.grey,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.grid_on),
label: '游戏',
),
BottomNavigationBarItem(
icon: Icon(Icons.bar_chart),
label: '统计',
),
BottomNavigationBarItem(
icon: Icon(Icons.calendar_today),
label: '每日挑战',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: '设置',
),
],
),
BottomNavigationBar是Flutter内置的底部导航栏组件。type设置为fixed确保所有项都显示标签。selectedItemColor和unselectedItemColor设置选中和未选中的颜色。BottomNavigationBarItem定义每个导航项的图标和标签。原生组件的优点是不需要额外依赖,缺点是样式相对简单。
添加页面切换动画。
dart
class _MainPageState extends State<MainPage> {
int _currentIndex = 0;
final List<Widget> _pages = [
const GamePage(),
const StatsPage(),
const DailyPage(),
const SettingsPage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _pages[_currentIndex],
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: child,
);
},
),
bottomNavigationBar: ConvexAppBar(
// ... 导航栏配置
),
);
}
}
AnimatedSwitcher在子widget变化时自动播放动画。duration设置动画时长为300毫秒。transitionBuilder定义动画效果,这里使用FadeTransition实现淡入淡出。注意AnimatedSwitcher不会保持页面状态,每次切换都会重建页面。如果需要保持状态,应该使用IndexedStack。
结合两者的优点。
dart
class _MainPageState extends State<MainPage> {
int _currentIndex = 0;
int _previousIndex = 0;
final List<Widget> _pages = [
const GamePage(),
const StatsPage(),
const DailyPage(),
const SettingsPage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// 使用Offstage保持所有页面状态
for (int i = 0; i < _pages.length; i++)
Offstage(
offstage: i != _currentIndex,
child: _pages[i],
),
],
),
bottomNavigationBar: ConvexAppBar(
// ... 导航栏配置
onTap: (index) {
setState(() {
_previousIndex = _currentIndex;
_currentIndex = index;
});
},
),
);
}
}
使用Stack和Offstage可以实现与IndexedStack相同的效果。Offstage的offstage属性控制子widget是否显示,但不显示时widget仍然存在于树中,状态得以保持。这种方式更灵活,可以添加自定义的切换动画。
处理返回键的行为。
dart
class _MainPageState extends State<MainPage> {
int _currentIndex = 0;
DateTime? _lastBackPress;
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
if (_currentIndex != 0) {
// 如果不在首页,先返回首页
setState(() {
_currentIndex = 0;
});
return false;
}
// 双击返回键退出
DateTime now = DateTime.now();
if (_lastBackPress == null ||
now.difference(_lastBackPress!) > const Duration(seconds: 2)) {
_lastBackPress = now;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('再按一次退出'),
duration: Duration(seconds: 2),
),
);
return false;
}
return true;
},
child: Scaffold(
// ... 页面内容
),
);
}
}
WillPopScope拦截返回键事件。如果当前不在首页,按返回键会先切换到首页。如果已经在首页,需要在2秒内连续按两次才能退出应用。SnackBar提示用户再按一次退出。这种设计防止用户误操作退出应用,是移动应用的常见模式。
导航栏的徽章显示。
dart
bottomNavigationBar: ConvexAppBar(
style: TabStyle.react,
backgroundColor: Colors.white,
activeColor: const Color(0xFF2196F3),
color: Colors.grey,
items: [
const TabItem(icon: Icons.grid_on, title: '游戏'),
const TabItem(icon: Icons.bar_chart, title: '统计'),
TabItem(
icon: Badge(
label: const Text('1'),
child: const Icon(Icons.calendar_today),
),
title: '每日挑战',
),
const TabItem(icon: Icons.settings, title: '设置'),
],
initialActiveIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
),
Badge组件可以在图标上显示小徽章,用于提示未读消息或待处理事项。这里在每日挑战图标上显示徽章,提示用户今天的挑战还未完成。Badge是Flutter 3.0引入的内置组件,使用简单方便。
动态更新徽章。
dart
class _MainPageState extends State<MainPage> {
int _currentIndex = 0;
bool _hasDailyChallenge = true;
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _pages,
),
bottomNavigationBar: ConvexAppBar(
items: [
const TabItem(icon: Icons.grid_on, title: '游戏'),
const TabItem(icon: Icons.bar_chart, title: '统计'),
TabItem(
icon: _hasDailyChallenge
? Badge(
backgroundColor: Colors.red,
child: const Icon(Icons.calendar_today),
)
: const Icon(Icons.calendar_today),
title: '每日挑战',
),
const TabItem(icon: Icons.settings, title: '设置'),
],
onTap: (index) {
setState(() {
_currentIndex = index;
if (index == 2) {
_hasDailyChallenge = false;
}
});
},
),
);
}
}
_hasDailyChallenge控制是否显示徽章。当用户点击每日挑战时,徽章消失。这种交互让用户知道有新内容等待他们,点击后标记为已查看。实际应用中,这个状态应该从数据层获取,比如检查今天的挑战是否已完成。
导航栏的主题适配。
dart
bottomNavigationBar: ConvexAppBar(
style: TabStyle.react,
backgroundColor: Theme.of(context).brightness == Brightness.dark
? Colors.grey.shade900
: Colors.white,
activeColor: Theme.of(context).primaryColor,
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey.shade400
: Colors.grey,
items: const [
TabItem(icon: Icons.grid_on, title: '游戏'),
TabItem(icon: Icons.bar_chart, title: '统计'),
TabItem(icon: Icons.calendar_today, title: '每日挑战'),
TabItem(icon: Icons.settings, title: '设置'),
],
initialActiveIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
),
通过Theme.of(context)获取当前主题,根据亮度模式设置不同的颜色。深色模式下使用深灰色背景和浅灰色图标,浅色模式下使用白色背景和深灰色图标。activeColor使用主题的primaryColor,保持与应用整体风格一致。
导航栏的无障碍支持。
dart
Semantics(
label: '底部导航栏',
child: ConvexAppBar(
items: [
TabItem(
icon: Semantics(
label: '游戏页面',
child: const Icon(Icons.grid_on),
),
title: '游戏',
),
TabItem(
icon: Semantics(
label: '统计页面',
child: const Icon(Icons.bar_chart),
),
title: '统计',
),
TabItem(
icon: Semantics(
label: '每日挑战页面',
child: const Icon(Icons.calendar_today),
),
title: '每日挑战',
),
TabItem(
icon: Semantics(
label: '设置页面',
child: const Icon(Icons.settings),
),
title: '设置',
),
],
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
),
),
Semantics组件为屏幕阅读器提供描述信息。每个导航项都有清晰的标签,让视障用户也能理解每个按钮的功能。无障碍支持是现代应用开发的重要组成部分,体现了应用的包容性。
保存和恢复导航状态。
dart
class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
int _currentIndex = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadSavedIndex();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_saveCurrentIndex();
}
}
Future<void> _loadSavedIndex() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_currentIndex = prefs.getInt('lastTabIndex') ?? 0;
});
}
Future<void> _saveCurrentIndex() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('lastTabIndex', _currentIndex);
}
WidgetsBindingObserver监听应用生命周期变化。当应用进入后台时保存当前导航索引,下次启动时恢复。SharedPreferences用于本地存储。这种设计让用户体验更连贯,应用会记住用户上次查看的页面。
导航栏的自定义样式。
dart
class CustomBottomNavBar extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
const CustomBottomNavBar({
super.key,
required this.currentIndex,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
height: 70.h,
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildNavItem(0, Icons.grid_on, '游戏'),
_buildNavItem(1, Icons.bar_chart, '统计'),
_buildNavItem(2, Icons.calendar_today, '每日'),
_buildNavItem(3, Icons.settings, '设置'),
],
),
);
}
Widget _buildNavItem(int index, IconData icon, String label) {
bool isSelected = currentIndex == index;
return GestureDetector(
onTap: () => onTap(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
decoration: BoxDecoration(
color: isSelected ? Colors.blue.withOpacity(0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(20.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 24.sp,
color: isSelected ? Colors.blue : Colors.grey,
),
SizedBox(height: 4.h),
Text(
label,
style: TextStyle(
fontSize: 12.sp,
color: isSelected ? Colors.blue : Colors.grey,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
);
}
}
CustomBottomNavBar是完全自定义的底部导航栏。AnimatedContainer在选中状态变化时提供平滑的动画过渡。选中项有浅蓝色背景和蓝色图标文字,未选中项是灰色。这种自定义设计可以完全控制导航栏的外观。
浮动导航栏样式。
dart
class FloatingBottomNavBar extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
const FloatingBottomNavBar({
super.key,
required this.currentIndex,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.all(16.w),
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 20,
spreadRadius: 2,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildNavItem(0, Icons.grid_on),
_buildNavItem(1, Icons.bar_chart),
_buildNavItem(2, Icons.calendar_today),
_buildNavItem(3, Icons.settings),
],
),
);
}
Widget _buildNavItem(int index, IconData icon) {
bool isSelected = currentIndex == index;
return GestureDetector(
onTap: () => onTap(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 50.w,
height: 50.h,
decoration: BoxDecoration(
color: isSelected ? Colors.blue : Colors.transparent,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 24.sp,
color: isSelected ? Colors.white : Colors.grey,
),
),
);
}
}
FloatingBottomNavBar是悬浮样式的导航栏。圆角矩形容器带有阴影,看起来像是浮在页面上方。选中项是蓝色圆形背景,图标变为白色。这种现代化的设计风格很受欢迎。
导航栏的滑动指示器。
dart
class SlidingIndicatorNavBar extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
const SlidingIndicatorNavBar({
super.key,
required this.currentIndex,
required this.onTap,
});
@override
Widget build(BuildContext context) {
double itemWidth = MediaQuery.of(context).size.width / 4;
return Container(
height: 60.h,
color: Colors.white,
child: Stack(
children: [
// 滑动指示器
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
left: currentIndex * itemWidth + itemWidth * 0.25,
bottom: 0,
child: Container(
width: itemWidth * 0.5,
height: 3.h,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(2.r),
),
),
),
// 导航项
Row(
children: [
_buildNavItem(0, Icons.grid_on, '游戏', itemWidth),
_buildNavItem(1, Icons.bar_chart, '统计', itemWidth),
_buildNavItem(2, Icons.calendar_today, '每日', itemWidth),
_buildNavItem(3, Icons.settings, '设置', itemWidth),
],
),
],
),
);
}
Widget _buildNavItem(int index, IconData icon, String label, double width) {
bool isSelected = currentIndex == index;
return GestureDetector(
onTap: () => onTap(index),
child: SizedBox(
width: width,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 24.sp,
color: isSelected ? Colors.blue : Colors.grey,
),
SizedBox(height: 4.h),
Text(
label,
style: TextStyle(
fontSize: 10.sp,
color: isSelected ? Colors.blue : Colors.grey,
),
),
],
),
),
);
}
}
SlidingIndicatorNavBar在底部有一个滑动的指示器。AnimatedPositioned让指示器平滑地移动到选中项下方。这种设计清晰地显示当前选中的位置,动画效果让切换更加流畅。
导航栏的中心凸起按钮。
dart
class CenterButtonNavBar extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
final VoidCallback onCenterTap;
const CenterButtonNavBar({
super.key,
required this.currentIndex,
required this.onTap,
required this.onCenterTap,
});
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
Container(
height: 60.h,
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildNavItem(0, Icons.grid_on, '游戏'),
_buildNavItem(1, Icons.bar_chart, '统计'),
SizedBox(width: 60.w), // 中心按钮的占位
_buildNavItem(2, Icons.calendar_today, '每日'),
_buildNavItem(3, Icons.settings, '设置'),
],
),
),
// 中心凸起按钮
Positioned(
top: -20.h,
left: 0,
right: 0,
child: Center(
child: GestureDetector(
onTap: onCenterTap,
child: Container(
width: 60.w,
height: 60.h,
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.blue.withOpacity(0.3),
blurRadius: 10,
spreadRadius: 2,
),
],
),
child: Icon(Icons.add, color: Colors.white, size: 30.sp),
),
),
),
),
],
);
}
Widget _buildNavItem(int index, IconData icon, String label) {
bool isSelected = currentIndex == index;
return GestureDetector(
onTap: () => onTap(index),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 24.sp, color: isSelected ? Colors.blue : Colors.grey),
Text(label, style: TextStyle(fontSize: 10.sp, color: isSelected ? Colors.blue : Colors.grey)),
],
),
);
}
}
CenterButtonNavBar在中间有一个凸起的圆形按钮。这个按钮可以用于快速开始新游戏等主要操作。Stack和Positioned让按钮可以超出导航栏的边界。这种设计突出了主要操作,是很多应用采用的模式。
导航栏的动态隐藏。
dart
class HideableBottomNavBar extends StatefulWidget {
final Widget child;
final Widget bottomNavBar;
const HideableBottomNavBar({
super.key,
required this.child,
required this.bottomNavBar,
});
@override
State<HideableBottomNavBar> createState() => _HideableBottomNavBarState();
}
class _HideableBottomNavBarState extends State<HideableBottomNavBar> {
bool _isVisible = true;
double _lastScrollPosition = 0;
void _onScroll(ScrollNotification notification) {
if (notification is ScrollUpdateNotification) {
double currentPosition = notification.metrics.pixels;
if (currentPosition > _lastScrollPosition && currentPosition > 50) {
// 向下滚动,隐藏导航栏
if (_isVisible) {
setState(() => _isVisible = false);
}
} else if (currentPosition < _lastScrollPosition) {
// 向上滚动,显示导航栏
if (!_isVisible) {
setState(() => _isVisible = true);
}
}
_lastScrollPosition = currentPosition;
}
}
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
_onScroll(notification);
return false;
},
child: Scaffold(
body: widget.child,
bottomNavigationBar: AnimatedSlide(
duration: const Duration(milliseconds: 200),
offset: _isVisible ? Offset.zero : const Offset(0, 1),
child: widget.bottomNavBar,
),
),
);
}
}
HideableBottomNavBar在用户向下滚动时自动隐藏导航栏,向上滚动时显示。NotificationListener监听滚动事件,AnimatedSlide提供平滑的滑入滑出动画。这种设计可以在需要更多屏幕空间时隐藏导航栏。
总结一下底部导航栏的关键设计要点。首先是状态保持,使用IndexedStack或Offstage保持子页面状态。其次是视觉反馈,选中项有明显的颜色或动画变化。然后是主题适配,根据亮度模式调整颜色。接着是多种样式选择,提供不同风格的导航栏设计。还有动态行为,支持滚动时隐藏等交互。最后是无障碍支持,为屏幕阅读器提供清晰的描述。
底部导航栏是应用导航的核心组件,它的设计直接影响用户体验。通过合理的布局和交互设计,我们可以让用户在应用的各个功能模块之间自由切换,同时保持良好的使用体验。选择合适的导航栏样式可以让应用更具个性和吸引力。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net