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 的官方参数。
备注:内容仅供参考。
