-
Notifications
You must be signed in to change notification settings - Fork 29.7k
Description
Steps to Reproduce
- Execute
flutter runon the code sample - Press the add (+) button to add a tab.
- Select the new tab.
- 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
Type
Projects
Status