TabBar scroll/slide making inaccuracy of displaying

Question

Asked by Phil D on November 09, 2021 (source).

I uses Container 's color to make indicator-like rather than using TabBar's indicator as I've to implement some animation to the Container.

When TabController index is changing, setState is called in the listener. Tries scroll/slide on the TabBar, the TabBar isn't properly changing the index, as listener doesn't listen to animation for TabBar.

I've tried using tabcontroller.animation.addListener method, but there isn't any workaround for me to control the scroll movement.

Attached video below demonstrates tapping and scroll/slide applied on the TabBar.

TabBar-Scroll/Slide

Code:

class TabTest extends StatefulWidget {
  @override
  _TabTestState createState() => _TabTestState();
}

class _TabTestState extends State<TabTest> with TickerProviderStateMixin {
  late TabController _tabController;
  late List<AnimationController> _animationControllers;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 4, vsync: this)
      ..addListener(_listener);
    _animationControllers = List.generate(
        4,
        (i) => AnimationController(
              vsync: this,
              duration: Duration(milliseconds: 750),
              reverseDuration: Duration(milliseconds: 350),
            ));
  }

  @override
  Widget build(BuildContext context) {
    List<IconData> _tabIconData = [
      Icons.card_giftcard,
      Icons.confirmation_num_outlined,
      Icons.emoji_events_outlined,
      Icons.wine_bar_outlined,
    ];

    List<String> _tabLabel = [
      'Tab1',
      'Tab2',
      'Tab3',
      'Tab4',
    ];

    Widget _tab({
      required IconData iconData,
      required String label,
      required bool isSelectedIndex,
      // required double widthAnimation,
      // required heightAnimation,
    }) {
      const _tabTextStyle = TextStyle(
          fontWeight: FontWeight.w300, fontSize: 12, color: Colors.black);
      return AnimatedContainer(
        duration: Duration(milliseconds: 300),
        padding: EdgeInsets.only(bottom: 2.0),
        height: 55,
        width: double.infinity, //_animContainerWidth - widthAnimation,
        decoration: BoxDecoration(
          border: Border(
            bottom: BorderSide(
              color: isSelectedIndex ? Colors.black : Colors.transparent,
              width: 2.0,
            ),
          ),
        ),
        child: Tab(
          iconMargin: EdgeInsets.only(bottom: 5.0),
          icon: Icon(iconData, color: Colors.black),
          child: Text(label, style: _tabTextStyle),
        ),
      );
    }

    List<Widget> _animationGenerator() {
      return List.generate(
        4,
        (index) => ClipRRect(
          child: AnimatedBuilder(
              animation: _animationControllers[index],
              builder: (ctx, _) {
                final value = _animationControllers[index].value;
                final angle = math.sin(value * math.pi * 2) * math.pi * 0.04;
                return Transform.rotate(
                    angle: angle,
                    child: _tab(
                      iconData: _tabIconData[index],
                      label: _tabLabel[index],
                      isSelectedIndex: _tabController.index == index,
                    ));
              }),
        ),
      );
    }

    return Scaffold(
      appBar: PreferredSize(
        preferredSize: Size.fromHeight(100),
        child: AppBar(
          iconTheme: Theme.of(context).iconTheme,
          title: Text(
            'Tab Bar',
            style: TextStyle(
              color: Colors.black,
              fontWeight: FontWeight.w400,
            ),
          ),
          centerTitle: true,
          bottom: PreferredSize(
            preferredSize: Size.fromHeight(20),
            child: Container(
              child: TabBar(
                controller: _tabController,
                labelPadding: EdgeInsets.only(top: 5.0, bottom: 2.0),
                indicatorColor: Colors.transparent,
                tabs: _animationGenerator(),
              ),
              decoration: BoxDecoration(
                color: Colors.white,
                boxShadow: [
                  BoxShadow(
                      color: Colors.white,
                      spreadRadius: 5.0,
                      offset: Offset(0, 3))
                ],
              ),
            ),
          ),
        ),
      ),
      body: TabBarView(
        controller: _tabController,
        children: List.generate(
            4,
            (index) => FittedBox(
                  child: Text('Tab $index'),
                )),
      ),
    );
  }

  void _listener() {
    if (_tabController.indexIsChanging) {
      setState(() {}); // To refresh color for Container bottom Border
      _animationControllers[_tabController.previousIndex].reverse();
    } else {
      _animationControllers[_tabController.index].forward();
    }
  }

  @override
  void dispose() {
    super.dispose();
    _tabController.removeListener(_listener);
  }
}

Answer

Question answered by pskink (source).

this is a solution with a CustomPaint widget driven by TabController.animation:

class TabTest extends StatefulWidget {
  @override
  _TabTestState createState() => _TabTestState();
}

class _TabTestState extends State<TabTest> with TickerProviderStateMixin {
  late TabController _tabController;
  late List<AnimationController> _animationControllers;

  @override
  void initState() {
    super.initState();
    // timeDilation = 10;
    _tabController = TabController(length: 4, vsync: this)
      ..addListener(_listener);
    _animationControllers = List.generate(4, (i) => AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 750),
    ));
  }

  @override
  Widget build(BuildContext context) {
    List<IconData> _tabIconData = [
      Icons.card_giftcard,
      Icons.confirmation_num_outlined,
      Icons.emoji_events_outlined,
      Icons.wine_bar_outlined,
    ];

    List<String> _tabLabel = [
      'Tab1',
      'Tab2',
      'Tab3',
      'Tab4',
    ];

    List<Color> _tabColor = [
      Color(0xffaa0000),
      Color(0xff00aa00),
      Color(0xff0000aa),
      Colors.black,
    ];

    Widget _tab({
      required IconData iconData,
      required String label,
      required Color color,
      required int index,
      required Animation<double>? animation,
    }) {
      const _tabTextStyle = TextStyle(fontWeight: FontWeight.w300, fontSize: 12, color: Colors.black);
      return CustomPaint(
        painter: TabPainter(
          animation: animation!,
          index: index,
          color: color,
        ),
        child: SizedBox(
          width: double.infinity,
          child: Tab(
            iconMargin: EdgeInsets.only(bottom: 5.0),
            icon: Icon(iconData, color: Colors.black),
            child: Text(label, style: _tabTextStyle),
          ),
        ),
      );
    }

    List<Widget> _animationGenerator() {
      return List.generate(
        4,
        (index) => AnimatedBuilder(
            animation: _animationControllers[index],
            builder: (ctx, _) {
              final value = _animationControllers[index].value;
              final angle = sin(value * pi * 3) * pi * 0.04;
              return Transform.rotate(
                  angle: angle,
                  child: _tab(
                    iconData: _tabIconData[index],
                    label: _tabLabel[index],
                    color: _tabColor[index],
                    index: index,
                    animation: _tabController.animation,
                  ));
            }),
      );
    }

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        title: Text('Tab Bar',
          style: TextStyle(
            color: Colors.black,
            fontWeight: FontWeight.w400,
          ),
        ),
        centerTitle: true,
        bottom: TabBar(
          controller: _tabController,
          labelPadding: EdgeInsets.only(top: 5.0, bottom: 2.0),
          indicatorColor: Colors.transparent,
          tabs: _animationGenerator(),
        ),
      ),
      body: TabBarView(
        controller: _tabController,
        children: List.generate(4, (index) => FittedBox(
          child: Text('Tab $index'),
        )),
      ),
    );
  }

  void _listener() {
    if (_tabController.indexIsChanging) {
      _animationControllers[_tabController.previousIndex].value = 0;
    } else {
      _animationControllers[_tabController.index].forward();
    }
  }

  @override
  void dispose() {
    super.dispose();
    _tabController
      ..removeListener(_listener)
      ..dispose();
    _animationControllers.forEach((ac) => ac.dispose());
  }
}

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

  TabPainter({
    required this.animation,
    required this.index,
    required this.color,
  });

  @override
  void paint(ui.Canvas canvas, ui.Size size) {
    // timeDilation = 10;
    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 = color);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
ANIMATION DART FLUTTER USER-EXPERIENCE USER-INTERFACE
SHARE: