Skip to content

[TabController]The following assertion was thrown while notifying listeners for AnimationController: Build scheduled during frame #102600

@kulny

Description

@kulny

Steps to Reproduce

  1. Execute flutter run on the code sample
  2. Press the add (+) button to add a tab.
  3. Select the new tab.
  4. Press the add button again.

Expected results:
A new tab appears without any additional issue.

Actual results:
Unexpected calls on a disposed controller.

Additional Information
I am trying to create a dynamic tab system for a flutter windows app. I have managed to get it mostly working, however there are some major flaws in the implementation that I am unsure how to handle.

First, this is sort of a second step in another issue I had, which can be further explained by https://stackoverflow.com/questions/72006417/build-scheduled-during-frame-after-changing-tab-in-tab-bar?noredirect=1#comment127252356_72006417. Without asking you to go and check that question for the details, though, the root of the issue was that an animation controller seemed to be getting built when it should not be, even though the TabController was given a Duration.zero value for the animation duration.

Eventually, I did manage to (seemingly) fix this, however it required me to change the index setter for an extended TabController:

set index(int value) {
    WidgetsBinding.instance?.addPostFrameCallback(
      (timeStamp) {
        _changeIndex(value);
      },
    );
  }

This change can be seen in the test.dart code below.

After this change, the issue with setstate during build calls was managed, however another issue cropped up: calls on a disposed controller.

The code I have been using to manage the state of the TabBar (which is a custom version of a tab bar that I have modified slightly from that of Yuriy Luchaninov's answer in How to create a dynamic TabBarView/ Render a new Tab with a function in Flutter? does not seem to have any issues in terms of disposal timing, so far as I can tell.

I'm pretty lost with this issue, so please let me know if I've completely missed anything fundamental. Please let me know if there's any more information I need to add.

Code sample main.dart
// ignore_for_file: constant_identifier_names

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:oddisy_tabs_prototype/providers.dart';
import 'package:oddisy_tabs_prototype/test.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Demo',
      theme: ThemeData.dark(),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  FocusNode currentTabFocusNode = FocusNode();
  List<TabObject> tabs = [
    TabObject(
      tabType: TabOjectType.CURRENT_PROJECT,
      tabName: 'Current Project',
    ),
    TabObject(
        tabType: TabOjectType.ENTRY,
        prefixImagePlaceholder: Icon(Icons.auto_awesome),
        tabName: 'test1'),
    // TabObject.createNew(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Consumer(builder: (context, ref, child) {
        // final tabs = ref.watch(tabsNotifierProvider);
        // if (tabs.isEmpty) {
        //   Future.delayed(
        //     Duration.zero,
        //     () => ref.read(tabsNotifierProvider.notifier).addTab(
        //         TabObject(tabType: TabOjectType.ENTRY, tabName: 'default')),
        //   );
        // }
        return CustomTabView(
            onAddPressed: () {
              // Future.delayed(
              //     Duration.zero,
              //     () => ref
              //         .read(tabsNotifierProvider.notifier)
              //         .addTab(TabObject(tabType: TabOjectType.EMPTY)));
              // tabs.add(TabObject(tabType: TabOjectType.EMPTY));

              // WidgetsBinding.instance?.addPostFrameCallback(
              //   (timeStamp) => setState(() {
              //     print(tabs.length);
              //     // tabs.add(TabObject(tabType: TabOjectType.EMPTY));
              //     // tabs.add(TabObject(tabType: TabOjectType.EMPTY));
              //     print(tabs.length);
              //   }),
              // );
              setState(() {
                tabs.add(TabObject(tabType: TabOjectType.EMPTY));
              });
            },
            onMiddleMouseButtonTapUp: (index) {
              // Future.delayed(
              //     Duration.zero,
              //     (() => ref
              //         .read(tabsNotifierProvider.notifier)
              //         .removeTabByIndex(index)));

              setState(() {
                tabs.removeAt(index);
              });
            },
            // onPositionChange: (value) {
            //   print(tabs[value].tabType);
            //   if (tabs[value].tabType == TabOjectType.CREATE_NEW) {
            //     print('create new tab pressed');
            //     setState(() {
            //       tabs[value] = TabObject(tabType: TabOjectType.EMPTY);
            //       tabs.add(TabObject.createNew());
            //     });
            //   }
            // },
            itemCount: tabs.length,
            tabBuilder: (context, index) {
              if (tabs[index].tabType == TabOjectType.CREATE_NEW) {
                return tabs[index].prefixImagePlaceholder;
              } else {
                return Row(
                  children: [
                    ConstrainedBox(
                        constraints:
                            const BoxConstraints(maxHeight: 25, maxWidth: 25),
                        child: tabs[index].prefixImagePlaceholder),
                    Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 8),
                      child: Text('${tabs[index].tabName}'),
                    ),
                    TextButton(
                        style: TextButton.styleFrom(
                          padding: EdgeInsets.zero,
                          minimumSize: Size.zero,
                          tapTargetSize: MaterialTapTargetSize.shrinkWrap,
                          shape: BeveledRectangleBorder(
                              borderRadius: BorderRadius.circular(5)),
                        ),
                        onPressed: () {
                          if (index != 0) {
                            setState(() {
                              tabs.removeAt(index);
                            });
                          } else {
                            // TODO close whole project
                          }
                        },
                        child: const Icon(
                          Icons.close,
                          color: Colors.grey,
                        ))
                  ],
                );
              }
            },
            pageBuilder: (context, index) {
              return RawKeyboardListener(
                focusNode: currentTabFocusNode,
                onKey: (value) {
                  if (value is RawKeyDownEvent) {
                    if (value.isControlPressed &&
                        value.logicalKey == LogicalKeyboardKey.keyW) {
                      print('ctrl w pressed');
                      setState(() {
                        tabs.removeAt(index);
                      });
                    }
                  }
                },
                child: Center(
                  child: Text('tab $index'),
                ),
              );
            });
      }),
    );
  }
}

enum TabOjectType {
  CREATE_NEW,
  ENTRY,
  HIGH_LEVEL,
  HOME,
  CURRENT_PROJECT,
  EMPTY,
}

class TabObject {
  static TabObject createNew() {
    return TabObject(
        tabType: TabOjectType.CREATE_NEW,
        prefixImagePlaceholder: Icon(Icons.add),
        tabName: '');
  }

  Widget prefixImagePlaceholder;

  /// this is the optionally provided actual object which the tab is referencing -- such as the entry of an entry page or its id
  dynamic object;
  String? tabName;
  TabOjectType tabType;

  TabObject(
      {required this.tabType,
      this.prefixImagePlaceholder = const Placeholder(
        fallbackHeight: 10,
        fallbackWidth: 10,
      ),
      this.object,
      this.tabName});
}

class CustomTabView extends StatefulWidget {
  final int itemCount;
  final IndexedWidgetBuilder tabBuilder;
  final IndexedWidgetBuilder pageBuilder;
  final Widget? stub;
  final ValueChanged<int>? onPositionChange;
  final int? initPosition;
  final void Function(int tabIndexPosition)? onMiddleMouseButtonTapUp;
  final VoidCallback onAddPressed;

  /// referenced from https://stackoverflow.com/questions/50036546/how-to-create-a-dynamic-tabbarview-render-a-new-tab-with-a-function-in-flutter
  /// with edits for our usage
  const CustomTabView({
    required this.itemCount,
    required this.tabBuilder,
    required this.pageBuilder,
    this.stub,
    this.onPositionChange,
    this.initPosition,
    this.onMiddleMouseButtonTapUp,
    required this.onAddPressed,
  });

  @override
  _CustomTabViewState createState() => _CustomTabViewState();
}

class _CustomTabViewState extends State<CustomTabView>
    with TickerProviderStateMixin {
  late TabControllerCustom controller;
  late int _currentCount;
  late int _currentPosition;

  @override
  void initState() {
    _currentPosition = widget.initPosition ?? 0;
    controller = TabControllerCustom(
        length: widget.itemCount,
        vsync: this,
        initialIndex: _currentPosition,
        animationDuration: Duration.zero);
    // controller.addListener(onPositionChange);
    _currentCount = widget.itemCount;
    super.initState();
  }

  @override
  void didUpdateWidget(CustomTabView oldWidget) {
    print(
        'current count = $_currentCount and widget count = ${widget.itemCount}');
    if (_currentCount != widget.itemCount) {
      print('did not equal previous item count');
      // controller.removeListener(onPositionChange);
      final oldController = controller;
      // controller.dispose();

      if (widget.initPosition != null) {
        _currentPosition = widget.initPosition!;
      }

      if (_currentPosition >= widget.itemCount - 1) {
        _currentPosition = widget.itemCount - 1;
        _currentPosition = _currentPosition < 0 ? 0 : _currentPosition;
        if (widget.onPositionChange is ValueChanged<int>) {
          WidgetsBinding.instance?.addPostFrameCallback((_) {
            if (mounted) {
              // widget.onPositionChange!(_currentPosition);
            }
          });
        }
      }

      _currentCount = widget.itemCount;
      setState(() {
        controller = TabControllerCustom(
            length: widget.itemCount,
            vsync: this,
            initialIndex: _currentPosition,
            animationDuration: Duration.zero);
        // controller.addListener(onPositionChange);
      });
      print('controller index ${oldController.hasListeners}');
      oldController.dispose();
    } else if (widget.initPosition != null) {
      print('did equal previous item count');

      controller.animateTo(widget.initPosition!);
    }

    super.didUpdateWidget(oldWidget);
  }

  @override
  void dispose() {
    // controller.removeListener(onPositionChange);
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (widget.itemCount < 1) return widget.stub ?? Container();

    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: <Widget>[
        Row(
          children: [
            Container(
              alignment: Alignment.centerLeft,
              child: GestureDetector(
                onTertiaryTapUp: (details) {
                  if (widget.onMiddleMouseButtonTapUp != null) {
                    widget.onMiddleMouseButtonTapUp!(_currentPosition);
                  }
                },
                child: TabBar(
                  padding: EdgeInsets.zero,
                  labelPadding: EdgeInsets.zero,
                  isScrollable: true,
                  controller: controller,
                  indicator: BoxDecoration(
                    color: Colors.grey.withOpacity(1),
                  ),
                  tabs: List.generate(
                    widget.itemCount,
                    (index) => widget.tabBuilder(context, index),
                  ),
                ),
              ),
            ),
            TextButton(
              onPressed: () {
                WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {
                  widget.onAddPressed();
                });
              },
              child: const Icon(Icons.add),
              style: TextButton.styleFrom(
                padding: EdgeInsets.zero,
                minimumSize: Size.zero,
                tapTargetSize: MaterialTapTargetSize.shrinkWrap,
                shape: BeveledRectangleBorder(
                    borderRadius: BorderRadius.circular(5)),
              ),
            )
          ],
        ),
        Expanded(
          child: TabBarView(
            controller: controller,
            children: List.generate(
              widget.itemCount,
              (index) => widget.pageBuilder(context, index),
            ),
          ),
        ),
      ],
    );
  }

  // onPositionChange() {
  //   if (!controller.indexIsChanging) {
  //     _currentPosition = controller.index;
  //     if (widget.onPositionChange is ValueChanged<int>) {
  //       widget.onPositionChange!(_currentPosition);
  //     }
  //   }
  // }
}

test.dart (which is where I have some widgets that I built to test some ideas for the issue)

import 'package:flutter/animation.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class TabControllerCustom extends TabController {
  TabControllerCustom(
      {int initialIndex = 0,
      Duration? animationDuration,
      required this.length,
      required TickerProvider vsync})
      : assert(length != null && length >= 0),
        assert(initialIndex != null &&
            initialIndex >= 0 &&
            (length == 0 || initialIndex < length)),
        _index = initialIndex,
        _previousIndex = initialIndex,
        _animationDuration = animationDuration ?? kTabScrollDuration,
        _animationController = AnimationController.unbounded(
          value: initialIndex.toDouble(),
          vsync: vsync,
        ),
        super(
            length: length,
            vsync: vsync,
            animationDuration: animationDuration,
            initialIndex: initialIndex);

  Animation<double>? get animation => _animationController?.view;
  AnimationController? _animationController;

  Duration get animationDuration => _animationDuration;
  final Duration _animationDuration;

  final int length;

  void _changeIndex(int value, {Duration? duration, Curve? curve}) {
    assert(value != null);
    assert(value >= 0 && (value < length || length == 0));
    assert(duration != null || curve == null);
    assert(_indexIsChangingCount >= 0);
    if (value == _index || length < 2) return;
    _previousIndex = index;
    _index = value;
    if (duration != null && duration > Duration.zero) {
      // _indexIsChangingCount += 1;
      // notifyListeners(); // Because the value of indexIsChanging may have changed.
      // _animationController!
      //     .animateTo(_index.toDouble(), duration: duration, curve: curve!)
      //     .whenCompleteOrCancel(() {
      //   if (_animationController != null) {
      //     // don't notify if we've been disposed
      //     _indexIsChangingCount -= 1;
      //     notifyListeners();
      //   }
      // });
    } else {
      _indexIsChangingCount += 1;
      if (_animationController != null) {
        _animationController!.value = _index.toDouble();
      }
      _indexIsChangingCount -= 1;
      notifyListeners();
    }
  }

  int get index => _index;
  int _index;
  set index(int value) {
    WidgetsBinding.instance?.addPostFrameCallback(
      (timeStamp) {
        _changeIndex(value);
      },
    );
  }

  int get previousIndex => _previousIndex;
  int _previousIndex;

  bool get indexIsChanging => _indexIsChangingCount != 0;
  int _indexIsChangingCount = 0;

  void animateTo(int value, {Duration? duration, Curve curve = Curves.ease}) {
    _changeIndex(value, duration: duration ?? _animationDuration, curve: curve);
  }

  double get offset => _animationController!.value - _index.toDouble();
  set offset(double value) {
    assert(value != null);
    assert(value >= -1.0 && value <= 1.0);
    assert(!indexIsChanging);
    if (value == offset) return;
    _animationController!.value = value + _index.toDouble();
  }

  @override
  void dispose() {
    print('controller dispose called and has listeners is $hasListeners');
    _animationController?.dispose();
    _animationController = null;
    super.dispose();
  }
}
Logs
info - Unused import: 'package:oddisy_tabs_prototype/providers.dart' - lib\main.dart:6:8 - unused_import
   info - Prefer const with constant constructors - lib\main.dart:45:33 - prefer_const_constructors        
   info - Avoid `print` calls in production code - lib\main.dart:151:23 - avoid_print
   info - Prefer const with constant constructors - lib\main.dart:181:33 - prefer_const_constructors       
   info - Use key in widget constructors - lib\main.dart:214:3 - use_key_in_widget_constructors
   info - Avoid `print` calls in production code - lib\main.dart:251:5 - avoid_print
   info - Avoid `print` calls in production code - lib\main.dart:254:7 - avoid_print
   info - Avoid `print` calls in production code - lib\main.dart:289:7 - avoid_print
   info - The member 'hasListeners' can only be used within instance members of subclasses of 'package:flutter/src/foundation/change_notifier.dart' - lib\main.dart:289:47 - invalid_use_of_protected_member        
   info - Avoid `print` calls in production code - lib\main.dart:292:7 - avoid_print
   info - The import of 'package:flutter/animation.dart' is unnecessary because all of the used elements are also provided by the import of 'package:flutter/cupertino.dart' - lib\test.dart:1:8 -
          unnecessary_import
   info - The import of 'package:flutter/cupertino.dart' is unnecessary because all of the used elements are also provided by the import of 'package:flutter/material.dart' - lib\test.dart:2:8 - unnecessary_import   info - The operand can't be null, so the condition is always true - lib\test.dart:11:23 - unnecessary_null_comparison
   info - The operand can't be null, so the condition is always true - lib\test.dart:12:29 - unnecessary_null_comparison
   info - Annotate overridden members - lib\test.dart:28:26 - annotate_overrides
   info - Annotate overridden members - lib\test.dart:31:16 - annotate_overrides
   info - Annotate overridden members - lib\test.dart:34:13 - annotate_overrides
   info - Don't override fields - lib\test.dart:34:13 - overridden_fields
   info - The operand can't be null, so the condition is always true - lib\test.dart:37:18 - unnecessary_null_comparison
   info - Annotate overridden members - lib\test.dart:66:11 - annotate_overrides
   info - Annotate overridden members - lib\test.dart:68:7 - annotate_overrides
   info - Annotate overridden members - lib\test.dart:76:11 - annotate_overrides
   info - Annotate overridden members - lib\test.dart:79:12 - annotate_overrides
   info - Annotate overridden members - lib\test.dart:82:8 - annotate_overrides
   info - Annotate overridden members - lib\test.dart:86:14 - annotate_overrides
   info - Annotate overridden members - lib\test.dart:87:7 - annotate_overrides
   info - The operand can't be null, so the condition is always true - lib\test.dart:88:18 - unnecessary_null_comparison
   info - Avoid `print` calls in production code - lib\test.dart:97:5 - avoid_print

28 issues found. (ran in 12.0s)
[√] Flutter (Channel stable, 2.10.5, on Microsoft Windows [Version 10.0.19043.1645], locale en-US)
    • Flutter version 2.10.5 at C:\flutter\flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 5464c5bac7 (8 days ago), 2022-04-18 09:55:37 -0700
    • Engine revision 57d3bac3dd
    • Dart version 2.16.2
    • DevTools version 2.9.2

[√] Android toolchain - develop for Android devices (Android SDK version 31.0.0-rc5)
    • Android SDK at C:\Users\Cody\AppData\Local\Android\sdk
    • Platform android-31, build-tools 31.0.0-rc5
    • Java binary at: C:\Program Files\Android\Android Studio\jre\bin\java
    • Java version OpenJDK Runtime Environment (build 11.0.11+9-b60-7590822)
    • All Android licenses accepted.

[√] Chrome - develop for the web
    • Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe

[√] Visual Studio - develop for Windows (Visual Studio Community 2019 16.10.2)
    • Visual Studio at C:\Program Files (x86)\Microsoft Visual Studio\2019\Community
    • Visual Studio Community 2019 version 16.10.31410.357
    • Windows 10 SDK version 10.0.19041.0

[√] Android Studio (version 2021.1)
    • Android Studio at C:\Program Files\Android\Android Studio
    • Flutter plugin can be installed from:
       https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
       https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 11.0.11+9-b60-7590822)

[√] VS Code, 64-bit edition (version 1.66.2)
    • VS Code at C:\Program Files\Microsoft VS Code
    • Flutter extension version 3.38.1

[√] Connected device (2 available)
    • Windows (desktop) • windows • windows-x64    • Microsoft Windows [Version 10.0.19043.1645]
    • Chrome (web)      • chrome  • web-javascript • Google Chrome 100.0.4896.75

[√] HTTP Host Availability
    • All required HTTP hosts are available

• No issues found!

Metadata

Metadata

Assignees

Labels

a: error messageError messages from the Flutter frameworkc: crashStack traces logged to the consolef: material designflutter/packages/flutter/material repository.found in release: 2.10Found to occur in 2.10found in release: 2.13Found to occur in 2.13frameworkflutter/packages/flutter repository. See also f: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onr: fixedIssue is closed as already fixed in a newer version

Type

No type

Projects

Status

Done (PR merged)

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions