diff --git a/dev/bots/check_code_samples.dart b/dev/bots/check_code_samples.dart index fd720dfc35f..13ab881db1a 100644 --- a/dev/bots/check_code_samples.dart +++ b/dev/bots/check_code_samples.dart @@ -346,7 +346,6 @@ final Set _knownMissingTests = { 'examples/api/test/material/search_anchor/search_anchor.1_test.dart', 'examples/api/test/material/search_anchor/search_anchor.2_test.dart', 'examples/api/test/material/about/about_list_tile.0_test.dart', - 'examples/api/test/material/tab_controller/tab_controller.1_test.dart', 'examples/api/test/material/selection_area/selection_area.0_test.dart', 'examples/api/test/material/scaffold/scaffold.end_drawer.0_test.dart', 'examples/api/test/material/scaffold/scaffold.drawer.0_test.dart', diff --git a/examples/api/lib/material/tab_controller/tab_controller.1.dart b/examples/api/lib/material/tab_controller/tab_controller.1.dart index 691980da683..d082cc85b64 100644 --- a/examples/api/lib/material/tab_controller/tab_controller.1.dart +++ b/examples/api/lib/material/tab_controller/tab_controller.1.dart @@ -11,42 +11,39 @@ void main() => runApp(const TabControllerExampleApp()); class TabControllerExampleApp extends StatelessWidget { const TabControllerExampleApp({super.key}); + static const List tabs = [ + Tab(text: 'Zeroth'), + Tab(text: 'First'), + Tab(text: 'Second'), + ]; + @override Widget build(BuildContext context) { return const MaterialApp( - home: TabControllerExample(), + home: TabControllerExample(tabs: tabs), ); } } -const List tabs = [ - Tab(text: 'Zeroth'), - Tab(text: 'First'), - Tab(text: 'Second'), -]; - class TabControllerExample extends StatelessWidget { - const TabControllerExample({super.key}); + const TabControllerExample({ + required this.tabs, + super.key, + }); + + final List tabs; @override Widget build(BuildContext context) { return DefaultTabController( length: tabs.length, - // The Builder widget is used to have a different BuildContext to access - // closest DefaultTabController. - child: Builder(builder: (BuildContext context) { - final TabController tabController = DefaultTabController.of(context); - tabController.addListener(() { - if (!tabController.indexIsChanging) { - // Your code goes here. - // To get index of current tab use tabController.index - } - }); - return Scaffold( + child: DefaultTabControllerListener( + onTabChanged: (int index) { + debugPrint('tab changed: $index'); + }, + child: Scaffold( appBar: AppBar( - bottom: const TabBar( - tabs: tabs, - ), + bottom: TabBar(tabs: tabs), ), body: TabBarView( children: tabs.map((Tab tab) { @@ -58,8 +55,75 @@ class TabControllerExample extends StatelessWidget { ); }).toList(), ), - ); - }), + ), + ), ); } } + +class DefaultTabControllerListener extends StatefulWidget { + const DefaultTabControllerListener({ + required this.onTabChanged, + required this.child, + super.key, + }); + + final ValueChanged onTabChanged; + + final Widget child; + + @override + State createState() => + _DefaultTabControllerListenerState(); +} + +class _DefaultTabControllerListenerState + extends State { + TabController? _controller; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final TabController? defaultTabController = + DefaultTabController.maybeOf(context); + + assert(() { + if (defaultTabController == null) { + throw FlutterError( + 'No DefaultTabController for ${widget.runtimeType}.\n' + 'When creating a ${widget.runtimeType}, you must ensure that there ' + 'is a DefaultTabController above the ${widget.runtimeType}.', + ); + } + return true; + }()); + + if (defaultTabController != _controller) { + _controller?.removeListener(_listener); + _controller = defaultTabController; + _controller?.addListener(_listener); + } + } + + void _listener() { + final TabController? controller = _controller; + + if (controller == null || controller.indexIsChanging) { + return; + } + + widget.onTabChanged(controller.index); + } + + @override + void dispose() { + _controller?.removeListener(_listener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/examples/api/test/material/tab_controller/tab_controller.1_test.dart b/examples/api/test/material/tab_controller/tab_controller.1_test.dart new file mode 100644 index 00000000000..b78550b17db --- /dev/null +++ b/examples/api/test/material/tab_controller/tab_controller.1_test.dart @@ -0,0 +1,96 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/tab_controller/tab_controller.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Verify first tab is selected by default', (WidgetTester tester) async { + await tester.pumpWidget( + const example.TabControllerExampleApp(), + ); + + final Tab firstTab = example.TabControllerExampleApp.tabs.first; + + expect( + find.descendant( + of: find.byType(TabBarView), + matching: find.text('${firstTab.text} Tab'), + ), + findsOneWidget, + ); + }); + + testWidgets('Verify tabs can be changed', (WidgetTester tester) async { + final List log = []; + + final DebugPrintCallback originalDebugPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + log.add(message); + }; + + await tester.pumpWidget( + const example.TabControllerExampleApp(), + ); + + const List tabs = example.TabControllerExampleApp.tabs; + final List tabsTraversalOrder = []; + + // The traverse order is from the second tab from the start to the last, + // and then from the second tab from the end to the first. + tabsTraversalOrder.addAll(tabs.skip(1)); + tabsTraversalOrder.addAll(tabs.reversed.skip(1)); + + for (final Tab tab in tabsTraversalOrder) { + // Tap on the TabBar's tab to select it. + await tester.tap(find.descendant( + of: find.byType(TabBar), + matching: find.text(tab.text!), + )); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(TabBarView), + matching: find.text('${tab.text} Tab'), + ), + findsOneWidget, + ); + + expect(log.length, equals(1)); + expect(log.last, equals('tab changed: ${tabs.indexOf(tab)}')); + + log.clear(); + } + + debugPrint = originalDebugPrint; + }); + + testWidgets('DefaultTabControllerListener throws when no DefaultTabController above', (WidgetTester tester) async { + await tester.pumpWidget( + example.DefaultTabControllerListener( + onTabChanged: (_) {}, + child: const SizedBox.shrink(), + ), + ); + + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + + final FlutterError error = exception as FlutterError; + expect( + error.toStringDeep(), + equalsIgnoringHashCodes( + 'FlutterError\n' + ' No DefaultTabController for DefaultTabControllerListener.\n' + ' When creating a DefaultTabControllerListener, you must ensure\n' + ' that there is a DefaultTabController above the\n' + ' DefaultTabControllerListener.\n', + ), + ); + }); +}