You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Flutter中实现带动画指示器的垂直Windows风格TabBar菜单

Flutter 带动画滑动指示器的顶部菜单实现

实现核心逻辑

  • 用GlobalKey标记每个菜单项,动态获取选中项的宽度与水平位置
  • 通过AnimationController实现指示器的平滑位移动画
  • 自定义CustomPainter同时绘制底部分割线与悬浮在分割线上的指示器,保证层级正确

完整可运行示例代码

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('动画菜单示例')),
        body: const AnimatedTabMenu(),
      ),
    );
  }
}

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

  @override
  State<AnimatedTabMenu> createState() => _AnimatedTabMenuState();
}

class _AnimatedTabMenuState extends State<AnimatedTabMenu> with SingleTickerProviderStateMixin {
  int _selectedIndex = 0;
  late AnimationController _animationController;
  final List<GlobalKey> _tabKeys = List.generate(4, (index) => GlobalKey());
  final List<String> _tabs = ['首页', '分类', '推荐', '我的'];
  
  double _indicatorX = 0;
  double _indicatorWidth = 0;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 250),
    );
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _updateIndicatorPosition(animate: false);
    });
  }

  void _updateIndicatorPosition({required bool animate}) {
    final renderBox = _tabKeys[_selectedIndex].currentContext?.findRenderObject() as RenderBox?;
    if (renderBox == null) return;
    final targetX = renderBox.localToGlobal(Offset.zero).dx;
    final targetWidth = renderBox.size.width;
    
    if (animate) {
      final oldX = _indicatorX;
      final oldWidth = _indicatorWidth;
      final anim = CurvedAnimation(parent: _animationController, curve: Curves.easeInOut);
      _animationController.reset();
      _animationController.addListener(() {
        setState(() {
          _indicatorX = oldX + (targetX - oldX) * anim.value;
          _indicatorWidth = oldWidth + (targetWidth - oldWidth) * anim.value;
        });
      });
      _animationController.forward();
    } else {
      setState(() {
        _indicatorX = targetX;
        _indicatorWidth = targetWidth;
      });
    }
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 菜单项区域
        Row(
          children: List.generate(_tabs.length, (index) {
            return Expanded(
              key: _tabKeys[index],
              child: GestureDetector(
                onTap: () {
                  if (_selectedIndex == index) return;
                  setState(() {
                    _selectedIndex = index;
                  });
                  _updateIndicatorPosition(animate: true);
                },
                child: Padding(
                  padding: const EdgeInsets.symmetric(vertical: 16),
                  child: Text(
                    _tabs[index],
                    textAlign: TextAlign.center,
                    style: TextStyle(
                      fontSize: 16,
                      fontWeight: _selectedIndex == index ? FontWeight.w600 : FontWeight.normal,
                      color: _selectedIndex == index ? Colors.blue : Colors.grey[700],
                    ),
                  ),
                ),
              ),
            );
          }),
        ),
        // 分割线+指示器区域
        CustomPaint(
          painter: IndicatorPainter(
            indicatorX: _indicatorX,
            indicatorWidth: _indicatorWidth,
            dividerColor: Colors.grey[300]!,
            indicatorColor: Colors.blue,
            dividerHeight: 1,
            indicatorHeight: 3,
          ),
          child: const SizedBox(height: 3, width: double.infinity),
        ),
      ],
    );
  }
}

class IndicatorPainter extends CustomPainter {
  final double indicatorX;
  final double indicatorWidth;
  final Color dividerColor;
  final Color indicatorColor;
  final double dividerHeight;
  final double indicatorHeight;

  IndicatorPainter({
    required this.indicatorX,
    required this.indicatorWidth,
    required this.dividerColor,
    required this.indicatorColor,
    required this.dividerHeight,
    required this.indicatorHeight,
  });

  @override
  void paint(Canvas canvas, Size size) {
    // 画底部分割线
    final dividerPaint = Paint()..color = dividerColor;
    canvas.drawRect(
      Rect.fromLTWH(0, size.height - dividerHeight, size.width, dividerHeight),
      dividerPaint,
    );
    // 画悬浮指示器
    if (indicatorWidth > 0) {
      final indicatorPaint = Paint()..color = indicatorColor;
      canvas.drawRRect(
        RRect.fromRectAndRadius(
          Rect.fromLTWH(
            indicatorX,
            size.height - indicatorHeight,
            indicatorWidth,
            indicatorHeight,
          ),
          const Radius.circular(2),
        ),
        indicatorPaint,
      );
    }
  }

  @override
  bool shouldRepaint(covariant IndicatorPainter oldDelegate) {
    return oldDelegate.indicatorX != indicatorX || oldDelegate.indicatorWidth != indicatorWidth;
  }
}

自定义调整说明

  • 如需修改菜单项内容,直接修改_tabs数组的文本,也可以替换成图标+文本的组合组件
  • 如需调整动画时长,修改AnimationController的duration参数即可
  • 如需修改分割线、指示器的颜色与尺寸,调整IndicatorPainter对应的传参即可
  • 如需修改指示器圆角,修改IndicatorPainter中RRect.fromRectAndRadius的radius参数即可

内容的提问来源于stack exchange,提问作者Steeven Delucis

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.04 17:30:01