背景
原有设计中,底部/侧边导航栏在切换页面时有复杂的动画效果:
- 背景指示器有滑动动画(从一个位置滑到另一个位置)
- 背景形状有 stretch 弹性效果(中间拉长再收缩)
- 桌面端有 InkWell 涟漪反馈
我认为这些动画不必要,希望简化交互。
原有实现分析
动画系统架构
class _HomePageState extends State<HomePage>
with SingleTickerProviderStateMixin { // 需要 vsync 混入
late final AnimationController _animController;
late Animation<double> _positionAnim; // 位置动画
late Animation<Color?> _colorAnim; // 颜色动画
// 颜色常量
static const _navColors = [
Colors.amber,
Color(0xFF1565C0),
Colors.green,
Colors.redAccent,
];
}动画触发逻辑
void _onPageChanged(int index) {
_prevIndex = _navIndex;
_navIndex = index;
// 每次切换都重新创建动画 Tween
_positionAnim = Tween<double>(
begin: _prevIndex.toDouble(),
end: _navIndex.toDouble(),
).animate(CurvedAnimation(parent: _animController, curve: Curves.easeOut));
_colorAnim = ColorTween(
begin: _navColors[_prevIndex],
end: _navColors[_navIndex],
).animate(CurvedAnimation(parent: _animController, curve: Curves.easeOut));
_animController.forward(from: 0); // 每次从头播放
}背景渲染(以移动端为例)
AnimatedBuilder(
animation: animController,
builder: (context, child) {
final pos = positionAnim.value; // 当前位置(0~3)
final progress = animController.value; // 动画进度(0~1)
final stretch = math.sin(progress * math.pi) * maxStretch; // 弹性拉伸
final currentWidth = circleSize + stretch; // 当前宽度
return Stack(
children: [
Positioned(
left: itemWidth * pos + (itemWidth - currentWidth) / 2,
child: Container(
width: currentWidth, // 宽度变化(弹性效果)
height: circleSize,
...
),
),
...
],
);
},
)页面切换逻辑
void _goToPage(int index) {
if (_isDesktop) {
_pageController.jumpToPage(index); // 桌面端:直接跳转
} else {
_pageController.animateToPage( // 移动端:带动画
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
}
}动机
我明确提出:
- 全端去掉按键点击后的背景(涟漪效果)
- 去掉相关的动画逻辑(滑动背景、stretch 效果)
- 移动端点击按钮也是直接跳转界面
- 只有移动端通过左右手势滑动时才保留滑动动画
关键点:手势滑动的动画由 PageView 自带处理,无需我们额外干预。
优化方案
方案对比
| 方案 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| A. 保留动画系统,只调参数 | 缩短动画时长到接近 0 | 改动小 | 代码冗余,性能浪费 |
| B. 完全移除动画系统 | 删除 AnimationController 及相关逻辑 | 代码精简,逻辑清晰 | 改动大 |
| C. 移除导航栏动画,保留 PageView 动画 | 分离两套动画系统 | 复杂,难维护 | 不推荐 |
选择方案 B - 完全移除动画系统,理由:
- 我明确不需要导航栏的动画效果
- 移动端手势滑动的动画是 PageView 内置的,不受影响
- 代码更简洁,易于维护
具体修改
1. State 类简化
// Before
class _HomePageState extends State<HomePage>
with SingleTickerProviderStateMixin {
int _navIndex = 0;
int _prevIndex = 0;
late final PageController _pageController;
late final AnimationController _animController;
late Animation<double> _positionAnim;
late Animation<Color?> _colorAnim;
static const _navColors = [...];
...
}
// After
class _HomePageState extends State<HomePage> {
int _navIndex = 0;
late final PageController _pageController;
...
}移除内容:
SingleTickerProviderStateMixin混入_prevIndex变量_animController控制器_positionAnim位置动画_colorAnim颜色动画_navColors常量
2. initState 简化
// Before
@override
void initState() {
super.initState();
_pageController = PageController();
_animController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
_positionAnim = Tween<double>(begin: 0, end: 0)
.animate(CurvedAnimation(parent: _animController, curve: Curves.easeOut));
_colorAnim = ColorTween(begin: _navColors[0], end: _navColors[0])
.animate(CurvedAnimation(parent: _animController, curve: Curves.easeOut));
...
}
// After
@override
void initState() {
super.initState();
_pageController = PageController();
...
}3. dispose 简化
// Before
@override
void dispose() {
_pageController.dispose();
_animController.dispose();
super.dispose();
}
// After
@override
void dispose() {
_pageController.dispose();
super.dispose();
}4. 页面切换统一为直接跳转
// Before
void _goToPage(int index) {
if (_isDesktop) {
_pageController.jumpToPage(index);
} else {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
}
}
// After
/// 点击按钮跳转页面(全端直接跳转,无动画)
void _goToPage(int index) {
if (index == _navIndex) return;
_pageController.jumpToPage(index);
}5. onPageChanged 简化
// Before
void _onPageChanged(int index) {
if (index == _navIndex) return;
_prevIndex = _navIndex;
_navIndex = index;
_positionAnim = Tween<double>(
begin: _prevIndex.toDouble(),
end: _navIndex.toDouble(),
).animate(CurvedAnimation(parent: _animController, curve: Curves.easeOut));
_colorAnim = ColorTween(
begin: _navColors[_prevIndex],
end: _navColors[_navIndex],
).animate(CurvedAnimation(parent: _animController, curve: Curves.easeOut));
_animController.forward(from: 0);
setState(() {});
}
// After
/// 页面切换回调(手势滑动时触发,保留动画)
void _onPageChanged(int index) {
if (index == _navIndex) return;
setState(() {
_navIndex = index;
});
}6. 桌面端侧边导航栏
// Before
class _DesktopSideNav extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
final AnimationController animController;
final Animation<double> positionAnim;
final Animation<Color?> colorAnim;
...
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animController,
builder: (context, child) {
final pos = positionAnim.value;
final progress = animController.value;
final stretch = math.sin(progress * math.pi) * 8.0;
final currentHeight = iconSize + stretch;
return Stack(
children: [
Positioned(
top: itemHeight * pos + (itemHeight - currentHeight) / 2,
child: Container(
height: currentHeight, // 动态高度
...
),
),
Column(
children: List.generate(4, (i) {
return Material(
child: InkWell( // 有涟漪效果
onTap: () => onTap(i),
...
),
);
}),
),
],
);
},
);
}
}
// After
class _DesktopSideNav extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
...
@override
Widget build(BuildContext context) {
return Stack(
children: [
// 当前选中项的背景(无动画,直接定位)
Positioned(
top: itemHeight * currentIndex + (itemHeight - iconSize) / 2,
child: Container(
height: iconSize, // 固定高度
...
),
),
Column(
children: List.generate(4, (i) {
return GestureDetector( // 无涟漪效果
behavior: HitTestBehavior.opaque,
onTap: () => onTap(i),
...
);
}),
),
],
);
}
}关键变化:
- 移除
AnimatedBuilder - 背景位置直接用
currentIndex计算,不用动画插值 InkWell改为GestureDetector(无涟漪)- 高度固定,不再有 stretch 效果
7. 移动端底部导航栏
同样的简化逻辑,类名从 _AnimatedBottomNav 改为 _MobileBottomNav。
// Before
class _AnimatedBottomNav extends StatelessWidget {
final Animation<double> positionAnim;
final Animation<Color?> colorAnim;
final AnimationController animController;
...
}
// After
class _MobileBottomNav extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
...
}8. 移除未使用的 import
// Before
import 'dart:math' as math;
// After
// (已删除,因为不再使用 math.sin)代码精简统计
| 指标 | Before | After | 变化 |
|---|---|---|---|
| 文件行数 | 439 | ~340 | -99 行 |
| State 变量 | 7 | 3 | -4 |
| 组件参数 | 5 | 2 | -3 |
| 混入类 | 1 | 0 | -1 |
最终交互行为
| 操作 | 桌面端 | 移动端 |
|---|---|---|
| 点击导航按钮 | 直接跳转,无动画 | 直接跳转,无动画 |
| 手势左右滑动 | 禁用(NeverScrollableScrollPhysics) | PageView 内置滑动动画 |
| 按钮视觉反馈 | 无(GestureDetector) | 无(GestureDetector) |
| 背景指示器 | 固定位置 | 固定位置 |
关键设计决策
为什么移动端手势滑动仍有动画?
因为 PageView 本身处理滑动手势时自带动画,这是 Flutter 的默认行为:
// 移动端 PageView(保留默认滑动行为)
PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
children: _pages,
// 没有设置 physics,使用默认的 PageScrollPhysics
)
// 桌面端 PageView(禁用滑动)
PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
children: _pages,
)手势滑动时,PageView 会自动处理:
- 跟随手指拖动
- 松手后的惯性滑动
- 自动对齐到最近页面
这些都是 PageView 内置的,我们只需要监听 onPageChanged 更新 UI 状态。
为什么用 GestureDetector 替代 InkWell?
- 无视觉反馈 - 我决定去掉点击后的背景效果
- 响应更直接 - 没有涟漪动画的感知延迟
- 代码更简单 - 不需要 Material 包裹
为什么保留背景指示器?
我只是要求去掉”动画”,背景指示器本身是有用的 UI 反馈:
- 明确当前选中的是哪个导航项
- 与图标颜色变化配合,提供清晰的状态指示
现在背景指示器是”瞬间切换”而非”滑动过渡”。
测试要点
- 桌面端点击导航 - 应直接切换,无滑动动画
- 移动端点击导航 - 应直接切换,无滑动动画
- 移动端手势滑动 - 应有平滑的滑动动画
- 按钮无涟漪 - 点击时不应有涟漪效果
- 背景指示器 - 应正确跟随当前选中项
相关文件
lib/ui/home_page.dart- 主要修改文件