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

Flutter使用flutter_animator实现TabBar滑动切换同步触发动画的问题

问题核心原因

  1. 原代码监听TabController.animation的逐帧回调逻辑错误:滑动切换时动画状态会连续触发多次,且_tabController.index仅在切换完成后才更新,滑动过程中读取到的仍是旧索引,导致前序标签被错误触发动画。
  2. 未做索引变化的去重判断,单次切换会多次调用动画播放方法。
  3. TabPainter的shouldRepaint返回false,导致自定义指示器无法跟随滑动状态实时重绘。

完整修改方案

修改后的代码已统一兼容点击切换、滑动切换两种场景,动画表现完全一致:

import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_animator/flutter_animator.dart';

class Playground extends StatefulWidget {
  @override
  _PlaygroundState createState() => _PlaygroundState();
}

class _PlaygroundState extends State<Playground> with TickerProviderStateMixin {
  late TabController _tabController;
  late final List<GlobalKey<AnimatorWidgetState>> _animKeys;
  // 记录上一次选中的索引,避免重复触发动画
  late int _lastSelectedIndex;

  @override
  void initState() {
    super.initState();
    _lastSelectedIndex = 0;
    _tabController = TabController(length: 4, vsync: this)
      // 统一监听Tab切换事件,兼容点击和滑动两种场景
      ..addListener(_onTabChanged);

    _animKeys = List.generate(
      4,
      (index) => GlobalKey<AnimatorWidgetState>(),
    );
  }

  @override
  void dispose() {
    _tabController.dispose();
    for (var key in _animKeys) {
      key.currentState?.dispose();
    }
    super.dispose();
  }

  void _onTabChanged() {
    // 获取当前目标索引:点击切换时直接取index,滑动切换时取动画值的四舍五入得到即将选中的索引
    final currentIndex = _tabController.indexIsChanging
        ? _tabController.index
        : _tabController.animation!.value.round();
    // 仅索引发生变化时才触发动画
    if (currentIndex != _lastSelectedIndex) {
      _lastSelectedIndex = currentIndex;
      // 先重置动画状态再播放,避免上次动画未播放完成导致的异常
      _animKeys[currentIndex].currentState?.reset();
      _animKeys[currentIndex].currentState?.forward();
    }
  }

  @override
  Widget build(BuildContext context) {
    List<Widget> _tabs = List.generate(4, (index) {
      return ClipRRect(
        child: ConstrainedBox(
          constraints: const BoxConstraints(
            minWidth: 100,
            maxHeight: 55,
          ),
          child: Tada(
            preferences: const AnimationPreferences(
              duration: Duration(milliseconds: 1000),
              autoPlay: AnimationPlayStates.None,
            ),
            key: _animKeys[index],
            child: CustomPaint(
              painter: TabPainter(
                animation: _tabController.animation!,
                index: index,
              ),
              child: Tab(
                child: Text(
                  'Tab $index',
                  style: const TextStyle(color: Colors.black),
                ),
              ),
            ),
          ),
        ),
      );
    });

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.blue,
        elevation: 0,
        title: const Text(
          'Tab Bar',
          style: TextStyle(
            color: Colors.black,
            fontWeight: FontWeight.w400,
          ),
        ),
        centerTitle: true,
        bottom: TabBar(
          // 无需单独配置onTap,监听已统一处理
          controller: _tabController,
          labelPadding: const EdgeInsets.only(top: 5.0, bottom: 2.0),
          indicatorColor: Colors.transparent,
          tabs: _tabs,
        ),
      ),
      body: TabBarView(
        controller: _tabController,
        children: List.generate(
            4,
            (index) => FittedBox(
                  child: Text('Tab $index'),
                )),
      ),
    );
  }
}

class TabPainter extends CustomPainter {
  final Animation<double> animation;
  final int index;
  final tabPaint = Paint();

  TabPainter({
    required this.animation,
    required this.index,
  }) : super(repaint: animation);

  @override
  void paint(ui.Canvas canvas, ui.Size size) {
    if ((animation.value - index).abs() < 1) {
      final rect = Offset.zero & size;
      canvas.clipRect(rect);
      canvas.translate(size.width * (animation.value - index), 0);
      final tabRect =
          Alignment.bottomCenter.inscribe(Size(size.width, 3), rect);
      canvas.drawRect(tabRect, tabPaint..color = Colors.black);
    }
  }

  // 配置正确的重绘判断逻辑
  @override
  bool shouldRepaint(covariant TabPainter oldDelegate) =>
      oldDelegate.animation != animation || oldDelegate.index != index;
}

优化点说明

  • 统一使用TabController的全局监听处理切换逻辑,无需分开配置点击和滑动事件
  • 新增索引变化去重判断,避免重复触发动画
  • 动画播放前先重置状态,解决动画未播放完成导致的卡顿异常
  • 修复TabPainter的重绘逻辑,自定义指示器可跟随滑动实时更新

内容的提问来源于stack exchange,提问作者Phil D

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.25 07:36:02