PAYATHON 2026

Flutter:如何实现类似 WhatsApp 的底部导航选中填充动画?

数据小陈

可以实现。icon 和 activeIcon 分别指定两种状态下显示的图标,但它们不会自动生成从轮廓到实心的形状变换,也没有定义绿色填充动画。要分别控制背景、图标和标签的过渡,可以将自定义导航栏放入 Scaffold.bottomNavigationBar。

这里的“绿色填充”指图标背后的绿色胶囊背景逐渐显现。切换时,图标通过淡入和缩放切换,标签的颜色和字重也随选中状态变化。

为什么现有代码没有完整的填充效果?

现有代码已经完成了选中状态管理:点击标签页后,setState 更新 _index,BottomNavigationBar 根据 currentIndex 显示对应的 activeIcon。

不过,指定两个 IconData 不会定义它们之间的路径动画。即使导航组件本身有状态过渡,也不会因此产生指定的绿色胶囊填充效果。

可以分别处理这三个部分:

  • AnimatedContainer:让图标背景在透明色与浅绿色之间过渡。
  • AnimatedSwitcher:切换轮廓图标和选中图标,并加入淡入、缩放动画。
  • AnimatedDefaultTextStyle:过渡标签的颜色和字重。

完整实现

以下示例保留了原来的两个标签页和图标配置,可以作为独立页面运行:

import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(home: NavigationDemo()));
}

class NavigationDemo extends StatefulWidget {
  const NavigationDemo({super.key});

  @override
  State<NavigationDemo> createState() => _NavigationDemoState();
}

class _NavigationDemoState extends State<NavigationDemo> {
  static const _duration = Duration(milliseconds: 250);
  static const _selectedColor = Color(0xFF075E54);
  static const _indicatorColor = Color(0xFFD9FDD3);

  int _index = 0;

  Widget _buildItem({
    required int index,
    required String label,
    required IconData icon,
    required IconData activeIcon,
  }) {
    final selected = _index == index;
    final color = selected ? _selectedColor : Colors.grey.shade700;

    return Expanded(
      child: Semantics(
        button: true,
        selected: selected,
        label: label,
        child: InkWell(
          onTap: () {
            if (_index == index) return;
            setState(() => _index = index);
          },
          child: ExcludeSemantics(
            child: Padding(
              padding: const EdgeInsets.symmetric(vertical: 8),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  AnimatedContainer(
                    duration: _duration,
                    curve: Curves.easeOutCubic,
                    width: 64,
                    height: 32,
                    decoration: BoxDecoration(
                      color: selected
                          ? _indicatorColor
                          : Colors.transparent,
                      borderRadius: BorderRadius.circular(16),
                    ),
                    child: Center(
                      child: AnimatedSwitcher(
                        duration: _duration,
                        switchInCurve: Curves.easeOutCubic,
                        switchOutCurve: Curves.easeInCubic,
                        transitionBuilder: (child, animation) {
                          return FadeTransition(
                            opacity: animation,
                            child: ScaleTransition(
                              scale: Tween<double>(
                                begin: 0.88,
                                end: 1,
                              ).animate(animation),
                              child: child,
                            ),
                          );
                        },
                        child: Icon(
                          selected ? activeIcon : icon,
                          key: ValueKey<bool>(selected),
                          color: color,
                          size: 24,
                        ),
                      ),
                    ),
                  ),
                  const SizedBox(height: 4),
                  AnimatedDefaultTextStyle(
                    duration: _duration,
                    curve: Curves.easeOutCubic,
                    style: TextStyle(
                      color: color,
                      fontSize: 12,
                      fontWeight: selected
                          ? FontWeight.w600
                          : FontWeight.w400,
                    ),
                    child: Text(label),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text(_index == 0 ? 'Chats' : 'Updates'),
      ),
      bottomNavigationBar: Material(
        color: Colors.white,
        child: SafeArea(
          top: false,
          child: Row(
            children: [
              _buildItem(
                index: 0,
                label: 'Chats',
                icon: Icons.chat_outlined,
                activeIcon: Icons.chat,
              ),
              _buildItem(
                index: 1,
                label: 'Updates',
                icon: Icons.update_outlined,
                activeIcon: Icons.update,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

哪些细节决定动画是否生效?

AnimatedSwitcher 使用 ValueKey<bool>(selected) 区分选中前后的子组件。选中状态改变时,key 也会改变,组件因此将新的 Icon 视为需要切换的子组件。仅修改同一个 Icon 的属性,不会触发这里定义的子组件切换动画。

绿色背景的尺寸固定为 64 × 32,选中前后不会挤动相邻标签。AnimatedContainer 只对背景颜色进行插值。如果希望胶囊从窄到宽展开,可以让 width 随 selected 改变,同时保留外层 Expanded,稳定每个导航项的位置。

AnimatedDefaultTextStyle 为标签提供可过渡的默认样式。示例中的 Text 没有单独设置 style,因此会使用这套动画样式。

这些组件属于隐式动画或自行管理过渡的组件,示例中不需要额外创建和释放 AnimationController。

如果需要的是图标内部逐渐填满

上面的代码让绿色背景显现,并在轮廓图标和实心图标之间淡入切换,不会让绿色逐步填满图标内部。

要实现从左到右或从下到上的图标填充,可以叠放轮廓图标和实心图标,用 ClipRect 配合动画逐步显示上层实心图标。若要让轮廓路径连续变成实心形状,则需要可插值的路径或专门的动画资源,仅切换两个 IconData 无法实现。

Material Icons 中,名称带 _outlined 和不带后缀的图标不一定有明显的空心、实心差异。原例中的 Icons.update_outlined 与 Icons.update 是否符合视觉需求,应以实际显示效果为准。如果两者外观接近,需要换用差异更明显的图标或自定义资源。

能否使用标准导航组件?

如果项目采用 Material 3,且标准胶囊指示器已经符合设计要求,可以优先考虑 NavigationBar,通过 NavigationDestination.icon、selectedIcon 和导航栏的 indicatorColor、animationDuration 配置选中效果。

当设计明确要求图标缩放、标签字重变化或自定义填充方向时,上面的自定义实现更方便控制。示例中的颜色和时长是可调整的设计参数,并非 WhatsApp 的官方参数。

备注:内容仅供参考。