// 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 'dart:typed_data'; import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; import '../image_data.dart'; import '../rendering/rendering_tester.dart' show TestCallbackPainter; late List<int> selectedTabs; class MockCupertinoTabController extends CupertinoTabController { MockCupertinoTabController({ required super.initialIndex }); bool isDisposed = false; int numOfListeners = 0; @override void addListener(VoidCallback listener) { numOfListeners++; super.addListener(listener); } @override void removeListener(VoidCallback listener) { numOfListeners--; super.removeListener(listener); } @override void dispose() { isDisposed = true; super.dispose(); } } void main() { setUp(() { selectedTabs = <int>[]; }); BottomNavigationBarItem tabGenerator(int index) { return BottomNavigationBarItem( icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), label: 'Tab ${index + 1}', ); } testWidgets('Tab switching', (WidgetTester tester) async { final List<int> tabsPainted = <int>[]; await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { return CustomPaint( painter: TestCallbackPainter( onPaint: () { tabsPainted.add(index); }, ), child: Text('Page ${index + 1}'), ); }, ), ), ); expect(tabsPainted, const <int>[0]); RichText tab1 = tester.widget(find.descendant( of: find.text('Tab 1'), matching: find.byType(RichText), )); expect(tab1.text.style!.color, CupertinoColors.activeBlue); RichText tab2 = tester.widget(find.descendant( of: find.text('Tab 2'), matching: find.byType(RichText), )); expect(tab2.text.style!.color!.value, 0xFF999999); await tester.tap(find.text('Tab 2')); await tester.pump(); expect(tabsPainted, const <int>[0, 1]); tab1 = tester.widget(find.descendant( of: find.text('Tab 1'), matching: find.byType(RichText), )); expect(tab1.text.style!.color!.value, 0xFF999999); tab2 = tester.widget(find.descendant( of: find.text('Tab 2'), matching: find.byType(RichText), )); expect(tab2.text.style!.color, CupertinoColors.activeBlue); await tester.tap(find.text('Tab 1')); await tester.pump(); expect(tabsPainted, const <int>[0, 1, 0]); // CupertinoTabBar's onTap callbacks are passed on. expect(selectedTabs, const <int>[1, 0]); }); testWidgets('Tabs are lazy built and moved offstage when inactive', (WidgetTester tester) async { final List<int> tabsBuilt = <int>[]; await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { tabsBuilt.add(index); return Text('Page ${index + 1}'); }, ), ), ); expect(tabsBuilt, const <int>[0]); expect(find.text('Page 1'), findsOneWidget); expect(find.text('Page 2'), findsNothing); await tester.tap(find.text('Tab 2')); await tester.pump(); // Both tabs are built but only one is onstage. expect(tabsBuilt, const <int>[0, 0, 1]); expect(find.text('Page 1', skipOffstage: false), isOffstage); expect(find.text('Page 2'), findsOneWidget); await tester.tap(find.text('Tab 1')); await tester.pump(); expect(tabsBuilt, const <int>[0, 0, 1, 0, 1]); expect(find.text('Page 1'), findsOneWidget); expect(find.text('Page 2', skipOffstage: false), isOffstage); }); testWidgets('Last tab gets focus', (WidgetTester tester) async { // 2 nodes for 2 tabs final List<FocusNode> focusNodes = <FocusNode>[ FocusNode(debugLabel: 'Node 1'), FocusNode(debugLabel: 'Node 2'), ]; await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { return CupertinoTextField( focusNode: focusNodes[index], autofocus: true, ); }, ), ), ); expect(focusNodes[0].hasFocus, isTrue); await tester.tap(find.text('Tab 2')); await tester.pump(); expect(focusNodes[0].hasFocus, isFalse); expect(focusNodes[1].hasFocus, isTrue); await tester.tap(find.text('Tab 1')); await tester.pump(); expect(focusNodes[0].hasFocus, isTrue); expect(focusNodes[1].hasFocus, isFalse); }); testWidgets('Do not affect focus order in the route', (WidgetTester tester) async { final List<FocusNode> focusNodes = <FocusNode>[ FocusNode(debugLabel: 'Node 1'), FocusNode(debugLabel: 'Node 2'), FocusNode(debugLabel: 'Node 3'), FocusNode(debugLabel: 'Node 4'), ]; await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { return Column( children: <Widget>[ CupertinoTextField( focusNode: focusNodes[index * 2], placeholder: 'TextField 1', ), CupertinoTextField( focusNode: focusNodes[index * 2 + 1], placeholder: 'TextField 2', ), ], ); }, ), ), ); expect( focusNodes.any((FocusNode node) => node.hasFocus), isFalse, ); await tester.tap(find.widgetWithText(CupertinoTextField, 'TextField 2')); expect( focusNodes.indexOf(focusNodes.singleWhere((FocusNode node) => node.hasFocus)), 1, ); await tester.tap(find.text('Tab 2')); await tester.pump(); await tester.tap(find.widgetWithText(CupertinoTextField, 'TextField 1')); expect( focusNodes.indexOf(focusNodes.singleWhere((FocusNode node) => node.hasFocus)), 2, ); await tester.tap(find.text('Tab 1')); await tester.pump(); // Upon going back to tab 1, the item it tab 1 that previously had the focus // (TextField 2) gets it back. expect( focusNodes.indexOf(focusNodes.singleWhere((FocusNode node) => node.hasFocus)), 1, ); }); testWidgets('Programmatic tab switching by changing the index of an existing controller', (WidgetTester tester) async { final CupertinoTabController controller = CupertinoTabController(initialIndex: 1); final List<int> tabsPainted = <int>[]; await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: _buildTabBar(), controller: controller, tabBuilder: (BuildContext context, int index) { return CustomPaint( painter: TestCallbackPainter( onPaint: () { tabsPainted.add(index); }, ), child: Text('Page ${index + 1}'), ); }, ), ), ); expect(tabsPainted, const <int>[1]); controller.index = 0; await tester.pump(); expect(tabsPainted, const <int>[1, 0]); // onTap is not called when changing tabs programmatically. expect(selectedTabs, isEmpty); // Can still tap out of the programmatically selected tab. await tester.tap(find.text('Tab 2')); await tester.pump(); expect(tabsPainted, const <int>[1, 0, 1]); expect(selectedTabs, const <int>[1]); }); testWidgets('Programmatic tab switching by passing in a new controller', (WidgetTester tester) async { final List<int> tabsPainted = <int>[]; await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { return CustomPaint( painter: TestCallbackPainter( onPaint: () { tabsPainted.add(index); }, ), child: Text('Page ${index + 1}'), ); }, ), ), ); expect(tabsPainted, const <int>[0]); await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: _buildTabBar(), controller: CupertinoTabController(initialIndex: 1), // Programmatically change the tab now. tabBuilder: (BuildContext context, int index) { return CustomPaint( painter: TestCallbackPainter( onPaint: () { tabsPainted.add(index); }, ), child: Text('Page ${index + 1}'), ); }, ), ), ); expect(tabsPainted, const <int>[0, 1]); // onTap is not called when changing tabs programmatically. expect(selectedTabs, isEmpty); // Can still tap out of the programmatically selected tab. await tester.tap(find.text('Tab 1')); await tester.pump(); expect(tabsPainted, const <int>[0, 1, 0]); expect(selectedTabs, const <int>[0]); }); testWidgets('Tab bar respects themes', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { return const Placeholder(); }, ), ), ); BoxDecoration tabDecoration = tester.widget<DecoratedBox>(find.descendant( of: find.byType(CupertinoTabBar), matching: find.byType(DecoratedBox), )).decoration as BoxDecoration; expect(tabDecoration.color, isSameColorAs(const Color(0xF0F9F9F9))); // Inherited from theme. await tester.tap(find.text('Tab 2')); await tester.pump(); // Pump again but with dark theme. await tester.pumpWidget( CupertinoApp( theme: const CupertinoThemeData( brightness: Brightness.dark, primaryColor: CupertinoColors.destructiveRed, ), home: CupertinoTabScaffold( tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { return const Placeholder(); }, ), ), ); tabDecoration = tester.widget<DecoratedBox>(find.descendant( of: find.byType(CupertinoTabBar), matching: find.byType(DecoratedBox), )).decoration as BoxDecoration; expect(tabDecoration.color, isSameColorAs(const Color(0xF01D1D1D))); final RichText tab1 = tester.widget(find.descendant( of: find.text('Tab 1'), matching: find.byType(RichText), )); // Tab 2 should still be selected after changing theme. expect(tab1.text.style!.color!.value, 0xFF757575); final RichText tab2 = tester.widget(find.descendant( of: find.text('Tab 2'), matching: find.byType(RichText), )); expect(tab2.text.style!.color, isSameColorAs(CupertinoColors.systemRed.darkColor)); }); testWidgets('Tab contents are padded when there are view insets', (WidgetTester tester) async { late BuildContext innerContext; await tester.pumpWidget( CupertinoApp( home: MediaQuery( data: const MediaQueryData( viewInsets: EdgeInsets.only(bottom: 200), ), child: CupertinoTabScaffold( tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { innerContext = context; return const Placeholder(); }, ), ), ), ); expect(tester.getRect(find.byType(Placeholder)), const Rect.fromLTWH(0, 0, 800, 400)); // Don't generate more media query padding from the translucent bottom // tab since the tab is behind the keyboard now. expect(MediaQuery.of(innerContext).padding.bottom, 0); }); testWidgets('Tab contents are not inset when resizeToAvoidBottomInset overridden', (WidgetTester tester) async { late BuildContext innerContext; await tester.pumpWidget( CupertinoApp( home: MediaQuery( data: const MediaQueryData( viewInsets: EdgeInsets.only(bottom: 200), ), child: CupertinoTabScaffold( resizeToAvoidBottomInset: false, tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { innerContext = context; return const Placeholder(); }, ), ), ), ); expect(tester.getRect(find.byType(Placeholder)), const Rect.fromLTWH(0, 0, 800, 600)); // Media query padding shows up in the inner content because it wasn't masked // by the view inset. expect(MediaQuery.of(innerContext).padding.bottom, 50); }); testWidgets('Tab contents bottom padding are not consumed by viewInsets when resizeToAvoidBottomInset overridden', (WidgetTester tester) async { final Widget child = Localizations( locale: const Locale('en', 'US'), delegates: const <LocalizationsDelegate<dynamic>>[ DefaultWidgetsLocalizations.delegate, DefaultCupertinoLocalizations.delegate, ], child: Directionality( textDirection: TextDirection.ltr, child: CupertinoTabScaffold( resizeToAvoidBottomInset: false, tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { return const Placeholder(); }, ), ), ); await tester.pumpWidget( CupertinoApp( home: MediaQuery( data: const MediaQueryData( viewInsets: EdgeInsets.only(bottom: 20.0), ), child: child, ), ), ); final Offset initialPoint = tester.getCenter(find.byType(Placeholder)); // Consume bottom padding - as if by the keyboard opening await tester.pumpWidget( MediaQuery( data: const MediaQueryData( viewPadding: EdgeInsets.only(bottom: 20), viewInsets: EdgeInsets.only(bottom: 300), ), child: child, ), ); final Offset finalPoint = tester.getCenter(find.byType(Placeholder)); expect(initialPoint, finalPoint); }); testWidgets( 'Opaque tab bar consumes bottom padding while non opaque tab bar does not', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/43581. Future<EdgeInsets> getContentPaddingWithTabBarColor(Color color) async { late EdgeInsets contentPadding; await tester.pumpWidget( CupertinoApp( home: MediaQuery( data: const MediaQueryData(padding: EdgeInsets.only(bottom: 50)), child: CupertinoTabScaffold( tabBar: CupertinoTabBar( backgroundColor: color, items: List<BottomNavigationBarItem>.generate(2, tabGenerator), ), tabBuilder: (BuildContext context, int index) { contentPadding = MediaQuery.of(context).padding; return const Placeholder(); }, ), ), ), ); return contentPadding; } expect(await getContentPaddingWithTabBarColor(const Color(0xAAFFFFFF)), isNot(EdgeInsets.zero)); expect(await getContentPaddingWithTabBarColor(const Color(0xFFFFFFFF)), EdgeInsets.zero); }, ); testWidgets('Tab and page scaffolds do not double stack view insets', (WidgetTester tester) async { late BuildContext innerContext; await tester.pumpWidget( CupertinoApp( home: MediaQuery( data: const MediaQueryData( viewInsets: EdgeInsets.only(bottom: 200), ), child: CupertinoTabScaffold( tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { return CupertinoPageScaffold( child: Builder( builder: (BuildContext context) { innerContext = context; return const Placeholder(); }, ), ); }, ), ), ), ); expect(tester.getRect(find.byType(Placeholder)), const Rect.fromLTWH(0, 0, 800, 400)); expect(MediaQuery.of(innerContext).padding.bottom, 0); }); testWidgets('Deleting tabs after selecting them should switch to the last available tab', (WidgetTester tester) async { final List<int> tabsBuilt = <int>[]; await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(4, tabGenerator), onTap: (int newTab) => selectedTabs.add(newTab), ), tabBuilder: (BuildContext context, int index) { tabsBuilt.add(index); return Text('Page ${index + 1}'); }, ), ), ); expect(tabsBuilt, const <int>[0]); // selectedTabs list is appended to on onTap callbacks. We didn't tap // any tabs yet. expect(selectedTabs, const <int>[]); tabsBuilt.clear(); await tester.tap(find.text('Tab 4')); await tester.pump(); // Tabs 1 and 4 are built but only one is onstage. expect(tabsBuilt, const <int>[0, 3]); expect(selectedTabs, const <int>[3]); expect(find.text('Page 1', skipOffstage: false), isOffstage); expect(find.text('Page 4'), findsOneWidget); tabsBuilt.clear(); // Delete 2 tabs while Page 4 is still selected. await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(2, tabGenerator), onTap: (int newTab) => selectedTabs.add(newTab), ), tabBuilder: (BuildContext context, int index) { tabsBuilt.add(index); // Change the builder too. return Text('Different page ${index + 1}'); }, ), ), ); expect(tabsBuilt, const <int>[0, 1]); // We didn't tap on any additional tabs to invoke the onTap callback. We // just deleted a tab. expect(selectedTabs, const <int>[3]); // Tab 1 was previously built so it's rebuilt again, albeit offstage. expect(find.text('Different page 1', skipOffstage: false), isOffstage); // Since all the tabs after tab 2 are deleted, tab 2 is now the last tab and // the actively shown tab. expect(find.text('Different page 2'), findsOneWidget); // No more tab 4 since it's deleted. expect(find.text('Different page 4', skipOffstage: false), findsNothing); // We also changed the builder so no tabs should be built with the old // builder. expect(find.text('Page 1', skipOffstage: false), findsNothing); expect(find.text('Page 2', skipOffstage: false), findsNothing); expect(find.text('Page 4', skipOffstage: false), findsNothing); }); // Regression test for https://github.com/flutter/flutter/issues/33455 testWidgets('Adding new tabs does not crash the app', (WidgetTester tester) async { final List<int> tabsPainted = <int>[]; final CupertinoTabController controller = CupertinoTabController(); await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(10, tabGenerator), ), controller: controller, tabBuilder: (BuildContext context, int index) { return CustomPaint( painter: TestCallbackPainter( onPaint: () { tabsPainted.add(index); }, ), child: Text('Page ${index + 1}'), ); }, ), ), ); expect(tabsPainted, const <int> [0]); // Increase the num of tabs to 20. await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(20, tabGenerator), ), controller: controller, tabBuilder: (BuildContext context, int index) { return CustomPaint( painter: TestCallbackPainter( onPaint: () { tabsPainted.add(index); }, ), child: Text('Page ${index + 1}'), ); }, ), ), ); expect(tabsPainted, const <int> [0, 0]); await tester.tap(find.text('Tab 19')); await tester.pump(); // Tapping the tabs should still work. expect(tabsPainted, const <int>[0, 0, 18]); }); testWidgets( 'If a controller is initially provided then the parent stops doing so for rebuilds, ' 'a new instance of CupertinoTabController should be created and used by the widget, ' "while preserving the previous controller's tab index", (WidgetTester tester) async { final List<int> tabsPainted = <int>[]; final CupertinoTabController oldController = CupertinoTabController(); await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(10, tabGenerator), ), controller: oldController, tabBuilder: (BuildContext context, int index) { return CustomPaint( painter: TestCallbackPainter( onPaint: () { tabsPainted.add(index); }, ), child: Text('Page ${index + 1}'), ); }, ), ), ); expect(tabsPainted, const <int> [0]); await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(10, tabGenerator), ), tabBuilder: (BuildContext context, int index) { return CustomPaint( painter: TestCallbackPainter( onPaint: () { tabsPainted.add(index); }, ), child: Text('Page ${index + 1}'), ); }, ), ), ); expect(tabsPainted, const <int> [0, 0]); await tester.tap(find.text('Tab 2')); await tester.pump(); // Tapping the tabs should still work. expect(tabsPainted, const <int>[0, 0, 1]); oldController.index = 10; await tester.pump(); // Changing [index] of the oldController should not work. expect(tabsPainted, const <int> [0, 0, 1]); }, ); testWidgets( 'Do not call dispose on a controller that we do not own ' 'but do remove from its listeners when done listening to it', (WidgetTester tester) async { final MockCupertinoTabController mockController = MockCupertinoTabController(initialIndex: 0); await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(2, tabGenerator), ), controller: mockController, tabBuilder: (BuildContext context, int index) => const Placeholder(), ), ), ); expect(mockController.numOfListeners, 1); expect(mockController.isDisposed, isFalse); await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(2, tabGenerator), ), tabBuilder: (BuildContext context, int index) => const Placeholder(), ), ), ); expect(mockController.numOfListeners, 0); expect(mockController.isDisposed, isFalse); }, ); testWidgets('The owner can dispose the old controller', (WidgetTester tester) async { CupertinoTabController controller = CupertinoTabController(initialIndex: 2); await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(3, tabGenerator), ), controller: controller, tabBuilder: (BuildContext context, int index) => const Placeholder(), ), ), ); expect(find.text('Tab 1'), findsOneWidget); expect(find.text('Tab 2'), findsOneWidget); expect(find.text('Tab 3'), findsOneWidget); controller.dispose(); controller = CupertinoTabController(); await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(2, tabGenerator), ), controller: controller, tabBuilder: (BuildContext context, int index) => const Placeholder(), ), ), ); // Should not crash here. expect(find.text('Tab 1'), findsOneWidget); expect(find.text('Tab 2'), findsOneWidget); expect(find.text('Tab 3'), findsNothing); }); testWidgets('A controller can control more than one CupertinoTabScaffold, ' 'removal of listeners does not break the controller', (WidgetTester tester) async { final List<int> tabsPainted0 = <int>[]; final List<int> tabsPainted1 = <int>[]; MockCupertinoTabController controller = MockCupertinoTabController(initialIndex: 2); await tester.pumpWidget( CupertinoApp( home: CupertinoPageScaffold( child: Stack( children: <Widget>[ CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(3, tabGenerator), ), controller: controller, tabBuilder: (BuildContext context, int index) { return CustomPaint( painter: TestCallbackPainter( onPaint: () => tabsPainted0.add(index), ), ); }, ), CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(3, tabGenerator), ), controller: controller, tabBuilder: (BuildContext context, int index) { return CustomPaint( painter: TestCallbackPainter( onPaint: () => tabsPainted1.add(index), ), ); }, ), ], ), ), ), ); expect(tabsPainted0, const <int>[2]); expect(tabsPainted1, const <int>[2]); expect(controller.numOfListeners, 2); controller.index = 0; await tester.pump(); expect(tabsPainted0, const <int>[2, 0]); expect(tabsPainted1, const <int>[2, 0]); controller.index = 1; // Removing one of the tabs works. await tester.pumpWidget( CupertinoApp( home: CupertinoPageScaffold( child: Stack( children: <Widget>[ CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(3, tabGenerator), ), controller: controller, tabBuilder: (BuildContext context, int index) { return CustomPaint( painter: TestCallbackPainter( onPaint: () => tabsPainted0.add(index), ), ); }, ), ], ), ), ), ); expect(tabsPainted0, const <int>[2, 0, 1]); expect(tabsPainted1, const <int>[2, 0]); expect(controller.numOfListeners, 1); // Replacing controller works. controller = MockCupertinoTabController(initialIndex: 2); await tester.pumpWidget( CupertinoApp( home: CupertinoPageScaffold( child: Stack( children: <Widget>[ CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(3, tabGenerator), ), controller: controller, tabBuilder: (BuildContext context, int index) { return CustomPaint( painter: TestCallbackPainter( onPaint: () => tabsPainted0.add(index), ), ); }, ), ], ), ), ), ); expect(tabsPainted0, const <int>[2, 0, 1, 2]); expect(tabsPainted1, const <int>[2, 0]); expect(controller.numOfListeners, 1); }, ); testWidgets('Assert when current tab index >= number of tabs', (WidgetTester tester) async { final CupertinoTabController controller = CupertinoTabController(initialIndex: 2); try { await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(2, tabGenerator), ), controller: controller, tabBuilder: (BuildContext context, int index) => Text('Different page ${index + 1}'), ), ), ); } on AssertionError catch (e) { expect(e.toString(), contains('controller.index < tabBar.items.length')); } await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(3, tabGenerator), ), controller: controller, tabBuilder: (BuildContext context, int index) => Text('Different page ${index + 1}'), ), ), ); expect(tester.takeException(), null); controller.index = 10; await tester.pump(); final String message = tester.takeException().toString(); expect(message, contains('current index ${controller.index}')); expect(message, contains('with 3 tabs')); }); testWidgets("Don't replace focus nodes for existing tabs when changing tab count", (WidgetTester tester) async { final CupertinoTabController controller = CupertinoTabController(initialIndex: 2); final List<FocusScopeNode> scopes = List<FocusScopeNode>.filled(5, FocusScopeNode()); await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(3, tabGenerator), ), controller: controller, tabBuilder: (BuildContext context, int index) { scopes[index] = FocusScope.of(context); return Container(); }, ), ), ); for (int i = 0; i < 3; i++) { controller.index = i; await tester.pump(); } await tester.pump(); final List<FocusScopeNode> newScopes = <FocusScopeNode>[]; await tester.pumpWidget( CupertinoApp( home: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate(5, tabGenerator), ), controller: controller, tabBuilder: (BuildContext context, int index) { newScopes.add(FocusScope.of(context)); return Container(); }, ), ), ); for (int i = 0; i < 5; i++) { controller.index = i; await tester.pump(); } await tester.pump(); expect(scopes.sublist(0, 3), equals(newScopes.sublist(0, 3))); }); testWidgets('Current tab index cannot go below zero or be null', (WidgetTester tester) async { void expectAssertionError(VoidCallback callback, String errorMessage) { try { callback(); } on AssertionError catch (e) { expect(e.toString(), contains(errorMessage)); } } expectAssertionError(() => CupertinoTabController(initialIndex: -1), '>= 0'); final CupertinoTabController controller = CupertinoTabController(); expectAssertionError(() => controller.index = -1, '>= 0'); }); testWidgets('Does not lose state when focusing on text input', (WidgetTester tester) async { // Regression testing for https://github.com/flutter/flutter/issues/28457. await tester.pumpWidget( MediaQuery( data: const MediaQueryData(), child: CupertinoApp( home: CupertinoTabScaffold( tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { return const CupertinoTextField(); }, ), ), ), ); final EditableTextState editableState = tester.state<EditableTextState>(find.byType(EditableText)); await tester.enterText(find.byType(CupertinoTextField), "don't lose me"); await tester.pumpWidget( MediaQuery( data: const MediaQueryData( viewInsets: EdgeInsets.only(bottom: 100), ), child: CupertinoApp( home: CupertinoTabScaffold( tabBar: _buildTabBar(), tabBuilder: (BuildContext context, int index) { return const CupertinoTextField(); }, ), ), ), ); // The exact same state instance is still there. expect(tester.state<EditableTextState>(find.byType(EditableText)), editableState); expect(find.text("don't lose me"), findsOneWidget); }); testWidgets('textScaleFactor is set to 1.0', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: Builder(builder: (BuildContext context) { return MediaQuery( data: MediaQuery.of(context).copyWith(textScaleFactor: 99), child: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate( 10, (int i) => BottomNavigationBarItem(icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), label: '$i'), ), ), tabBuilder: (BuildContext context, int index) => const Text('content'), ), ); }), ), ); final Iterable<RichText> barItems = tester.widgetList<RichText>( find.descendant( of: find.byType(CupertinoTabBar), matching: find.byType(RichText), ), ); final Iterable<RichText> contents = tester.widgetList<RichText>( find.descendant( of: find.text('content'), matching: find.byType(RichText), skipOffstage: false, ), ); expect(barItems.length, greaterThan(0)); expect(barItems.any((RichText t) => t.textScaleFactor != 1), isFalse); expect(contents.length, greaterThan(0)); expect(contents.any((RichText t) => t.textScaleFactor != 99), isFalse); }); testWidgets('state restoration', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( restorationScopeId: 'app', home: CupertinoTabScaffold( restorationId: 'scaffold', tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate( 4, (int i) => BottomNavigationBarItem(icon: const Icon(CupertinoIcons.map), label: 'Tab $i'), ), ), tabBuilder: (BuildContext context, int i) => Text('Content $i'), ), ), ); expect(find.text('Content 0'), findsOneWidget); expect(find.text('Content 1'), findsNothing); expect(find.text('Content 2'), findsNothing); expect(find.text('Content 3'), findsNothing); await tester.tap(find.text('Tab 2')); await tester.pumpAndSettle(); expect(find.text('Content 0'), findsNothing); expect(find.text('Content 1'), findsNothing); expect(find.text('Content 2'), findsOneWidget); expect(find.text('Content 3'), findsNothing); await tester.restartAndRestore(); expect(find.text('Content 0'), findsNothing); expect(find.text('Content 1'), findsNothing); expect(find.text('Content 2'), findsOneWidget); expect(find.text('Content 3'), findsNothing); final TestRestorationData data = await tester.getRestorationData(); await tester.tap(find.text('Tab 1')); await tester.pumpAndSettle(); expect(find.text('Content 0'), findsNothing); expect(find.text('Content 1'), findsOneWidget); expect(find.text('Content 2'), findsNothing); expect(find.text('Content 3'), findsNothing); await tester.restoreFrom(data); expect(find.text('Content 0'), findsNothing); expect(find.text('Content 1'), findsNothing); expect(find.text('Content 2'), findsOneWidget); expect(find.text('Content 3'), findsNothing); }); testWidgets('switch from internal to external controller with state restoration', (WidgetTester tester) async { Widget buildWidget({CupertinoTabController? controller}) { return CupertinoApp( restorationScopeId: 'app', home: CupertinoTabScaffold( controller: controller, restorationId: 'scaffold', tabBar: CupertinoTabBar( items: List<BottomNavigationBarItem>.generate( 4, (int i) => BottomNavigationBarItem(icon: const Icon(CupertinoIcons.map), label: 'Tab $i'), ), ), tabBuilder: (BuildContext context, int i) => Text('Content $i'), ), ); } await tester.pumpWidget(buildWidget()); expect(find.text('Content 0'), findsOneWidget); expect(find.text('Content 1'), findsNothing); expect(find.text('Content 2'), findsNothing); expect(find.text('Content 3'), findsNothing); await tester.tap(find.text('Tab 2')); await tester.pumpAndSettle(); expect(find.text('Content 0'), findsNothing); expect(find.text('Content 1'), findsNothing); expect(find.text('Content 2'), findsOneWidget); expect(find.text('Content 3'), findsNothing); final CupertinoTabController controller = CupertinoTabController(initialIndex: 3); await tester.pumpWidget(buildWidget(controller: controller)); expect(find.text('Content 0'), findsNothing); expect(find.text('Content 1'), findsNothing); expect(find.text('Content 2'), findsNothing); expect(find.text('Content 3'), findsOneWidget); await tester.pumpWidget(buildWidget()); expect(find.text('Content 0'), findsOneWidget); expect(find.text('Content 1'), findsNothing); expect(find.text('Content 2'), findsNothing); expect(find.text('Content 3'), findsNothing); }); } CupertinoTabBar _buildTabBar({ int selectedTab = 0 }) { return CupertinoTabBar( items: <BottomNavigationBarItem>[ BottomNavigationBarItem( icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), label: 'Tab 1', ), BottomNavigationBarItem( icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), label: 'Tab 2', ), ], currentIndex: selectedTab, onTap: (int newTab) => selectedTabs.add(newTab), ); }