January 3, 2026
6 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

背景

原有设计中,底部/侧边导航栏在切换页面时有复杂的动画效果:

  • 背景指示器有滑动动画(从一个位置滑到另一个位置)
  • 背景形状有 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,
    );
  }
}

动机

我明确提出:

  1. 全端去掉按键点击后的背景(涟漪效果)
  2. 去掉相关的动画逻辑(滑动背景、stretch 效果)
  3. 移动端点击按钮也是直接跳转界面
  4. 只有移动端通过左右手势滑动时才保留滑动动画

关键点:手势滑动的动画由 PageView 自带处理,无需我们额外干预。

优化方案

方案对比

方案描述优点缺点
A. 保留动画系统,只调参数缩短动画时长到接近 0改动小代码冗余,性能浪费
B. 完全移除动画系统删除 AnimationController 及相关逻辑代码精简,逻辑清晰改动大
C. 移除导航栏动画,保留 PageView 动画分离两套动画系统复杂,难维护不推荐

选择方案 B - 完全移除动画系统,理由:

  1. 我明确不需要导航栏的动画效果
  2. 移动端手势滑动的动画是 PageView 内置的,不受影响
  3. 代码更简洁,易于维护

具体修改

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)

代码精简统计

指标BeforeAfter变化
文件行数439~340-99 行
State 变量73-4
组件参数52-3
混入类10-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 会自动处理:

  1. 跟随手指拖动
  2. 松手后的惯性滑动
  3. 自动对齐到最近页面

这些都是 PageView 内置的,我们只需要监听 onPageChanged 更新 UI 状态。

为什么用 GestureDetector 替代 InkWell?

  1. 无视觉反馈 - 我决定去掉点击后的背景效果
  2. 响应更直接 - 没有涟漪动画的感知延迟
  3. 代码更简单 - 不需要 Material 包裹

为什么保留背景指示器?

我只是要求去掉”动画”,背景指示器本身是有用的 UI 反馈:

  • 明确当前选中的是哪个导航项
  • 与图标颜色变化配合,提供清晰的状态指示

现在背景指示器是”瞬间切换”而非”滑动过渡”。

测试要点

  1. 桌面端点击导航 - 应直接切换,无滑动动画
  2. 移动端点击导航 - 应直接切换,无滑动动画
  3. 移动端手势滑动 - 应有平滑的滑动动画
  4. 按钮无涟漪 - 点击时不应有涟漪效果
  5. 背景指示器 - 应正确跟随当前选中项

相关文件

  • lib/ui/home_page.dart - 主要修改文件