diff --git a/examples/api/lib/material/tabs/tab_bar.onFocusChange.dart b/examples/api/lib/material/tabs/tab_bar.onFocusChange.dart new file mode 100644 index 0000000000..f868992d50 --- /dev/null +++ b/examples/api/lib/material/tabs/tab_bar.onFocusChange.dart @@ -0,0 +1,64 @@ +// 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/material.dart'; + +/// Flutter code sample for [TabBar.onFocusChange]. + +void main() => runApp(const TabBarApp()); + +class TabBarApp extends StatelessWidget { + const TabBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp(theme: ThemeData(useMaterial3: true), home: const TabBarExample()); + } +} + +class TabBarExample extends StatefulWidget { + const TabBarExample({super.key}); + + @override + State createState() => _TabBarExampleState(); +} + +class _TabBarExampleState extends State { + int? focusedIndex; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + initialIndex: 1, + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('TabBar Sample'), + bottom: TabBar( + onFocusChange: (bool value, int index) { + setState(() { + focusedIndex = switch (value) { + true => index, + false => null, + }; + }); + }, + tabs: [ + Tab(icon: Icon(Icons.cloud_outlined, size: focusedIndex == 0 ? 35 : 25)), + Tab(icon: Icon(Icons.beach_access_sharp, size: focusedIndex == 1 ? 35 : 25)), + Tab(icon: Icon(Icons.brightness_5_sharp, size: focusedIndex == 2 ? 35 : 25)), + ], + ), + ), + body: const TabBarView( + children: [ + Center(child: Text("It's cloudy here")), + Center(child: Text("It's rainy here")), + Center(child: Text("It's sunny here")), + ], + ), + ), + ); + } +} diff --git a/examples/api/lib/material/tabs/tab_bar.onHover.dart b/examples/api/lib/material/tabs/tab_bar.onHover.dart new file mode 100644 index 0000000000..f641833bf4 --- /dev/null +++ b/examples/api/lib/material/tabs/tab_bar.onHover.dart @@ -0,0 +1,64 @@ +// 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/material.dart'; + +/// Flutter code sample for [TabBar.onFocusChange]. + +void main() => runApp(const TabBarApp()); + +class TabBarApp extends StatelessWidget { + const TabBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp(theme: ThemeData(useMaterial3: true), home: const TabBarExample()); + } +} + +class TabBarExample extends StatefulWidget { + const TabBarExample({super.key}); + + @override + State createState() => _TabBarExampleState(); +} + +class _TabBarExampleState extends State { + final List tabColors = [Colors.purple, Colors.purple, Colors.purple]; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + initialIndex: 1, + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('TabBar Sample'), + bottom: TabBar( + onHover: (bool value, int index) { + setState(() { + tabColors[index] = switch (value) { + true => Colors.pink, + false => Colors.purple, + }; + }); + }, + tabs: [ + Tab(icon: Icon(Icons.cloud_outlined, color: tabColors[0])), + Tab(icon: Icon(Icons.beach_access_sharp, color: tabColors[1])), + Tab(icon: Icon(Icons.brightness_5_sharp, color: tabColors[2])), + ], + ), + ), + body: const TabBarView( + children: [ + Center(child: Text("It's cloudy here")), + Center(child: Text("It's rainy here")), + Center(child: Text("It's sunny here")), + ], + ), + ), + ); + } +} diff --git a/examples/api/test/material/tabs/tab_bar.onFocusChange_test.dart b/examples/api/test/material/tabs/tab_bar.onFocusChange_test.dart new file mode 100644 index 0000000000..3e8d7f99cb --- /dev/null +++ b/examples/api/test/material/tabs/tab_bar.onFocusChange_test.dart @@ -0,0 +1,61 @@ +// 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/material.dart'; +import 'package:flutter_api_samples/material/tabs/tab_bar.onFocusChange.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tabs change in response to focus', (WidgetTester tester) async { + await tester.pumpWidget(const example.TabBarApp()); + + final TabBar tabBar = tester.widget(find.byType(TabBar)); + expect(tabBar.tabs.length, 3); + + expect(tester.widget(find.byIcon(Icons.cloud_outlined)).size, 25); + expect(tester.widget(find.byIcon(Icons.beach_access_sharp)).size, 25); + expect(tester.widget(find.byIcon(Icons.brightness_5_sharp)).size, 25); + + // Focus on the first tab. + Element tabElement = tester.element(find.byIcon(Icons.cloud_outlined)); + FocusNode node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(tester.widget(find.byIcon(Icons.cloud_outlined)).size, 35); + expect(tester.widget(find.byIcon(Icons.beach_access_sharp)).size, 25); + expect(tester.widget(find.byIcon(Icons.brightness_5_sharp)).size, 25); + + // Move focus to the second tab + tabElement = tester.element(find.byIcon(Icons.beach_access_sharp)); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(tester.widget(find.byIcon(Icons.cloud_outlined)).size, 25); + expect(tester.widget(find.byIcon(Icons.beach_access_sharp)).size, 35); + expect(tester.widget(find.byIcon(Icons.brightness_5_sharp)).size, 25); + + // And the third + tabElement = tester.element(find.byIcon(Icons.brightness_5_sharp)); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(tester.widget(find.byIcon(Icons.cloud_outlined)).size, 25); + expect(tester.widget(find.byIcon(Icons.beach_access_sharp)).size, 25); + expect(tester.widget(find.byIcon(Icons.brightness_5_sharp)).size, 35); + + // Unfocus + node.unfocus(); + await tester.pump(); + await tester.pump(); + + expect(tester.widget(find.byIcon(Icons.cloud_outlined)).size, 25); + expect(tester.widget(find.byIcon(Icons.beach_access_sharp)).size, 25); + expect(tester.widget(find.byIcon(Icons.brightness_5_sharp)).size, 25); + }); +} diff --git a/examples/api/test/material/tabs/tab_bar.onHover_test.dart b/examples/api/test/material/tabs/tab_bar.onHover_test.dart new file mode 100644 index 0000000000..5acab61dc0 --- /dev/null +++ b/examples/api/test/material/tabs/tab_bar.onHover_test.dart @@ -0,0 +1,58 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/tabs/tab_bar.onHover.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tabs change in response to hover', (WidgetTester tester) async { + await tester.pumpWidget(const example.TabBarApp()); + + final TabBar tabBar = tester.widget(find.byType(TabBar)); + expect(tabBar.tabs.length, 3); + + expect(tester.widget(find.byIcon(Icons.cloud_outlined)).color, Colors.purple); + expect(tester.widget(find.byIcon(Icons.beach_access_sharp)).color, Colors.purple); + expect(tester.widget(find.byIcon(Icons.brightness_5_sharp)).color, Colors.purple); + + // Hover over the first tab. + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.cloud_outlined))); + await tester.pump(); + await tester.pump(); + expect(tester.widget(find.byIcon(Icons.cloud_outlined)).color, Colors.pink); + expect(tester.widget(find.byIcon(Icons.beach_access_sharp)).color, Colors.purple); + expect(tester.widget(find.byIcon(Icons.brightness_5_sharp)).color, Colors.purple); + + // Hover over the second tab + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.beach_access_sharp))); + await tester.pump(); + await tester.pump(); + expect(tester.widget(find.byIcon(Icons.cloud_outlined)).color, Colors.purple); + expect(tester.widget(find.byIcon(Icons.beach_access_sharp)).color, Colors.pink); + expect(tester.widget(find.byIcon(Icons.brightness_5_sharp)).color, Colors.purple); + + // And the third + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.brightness_5_sharp))); + await tester.pump(); + await tester.pump(); + expect(tester.widget(find.byIcon(Icons.cloud_outlined)).color, Colors.purple); + expect(tester.widget(find.byIcon(Icons.beach_access_sharp)).color, Colors.purple); + expect(tester.widget(find.byIcon(Icons.brightness_5_sharp)).color, Colors.pink); + + // Remove hover + await gesture.removePointer(); + await tester.pump(); + await tester.pump(); + expect(tester.widget(find.byIcon(Icons.cloud_outlined)).color, Colors.purple); + expect(tester.widget(find.byIcon(Icons.beach_access_sharp)).color, Colors.purple); + expect(tester.widget(find.byIcon(Icons.brightness_5_sharp)).color, Colors.purple); + }); +} diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index 67d8cafd42..7df2f5dcff 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -844,6 +844,15 @@ class _TabBarScrollController extends ScrollController { } } +/// Signature for [TabBar] callbacks that report that an underlying value has +/// changed for a given [Tab] at `index`. +/// +/// Used for [TabBar.onHover] and [TabBar.onFocusChange] callbacks The provided +/// `value` being true indicates focus has been gained, or a pointer has hovered +/// over the tab, with false indicated focus has been lost or the pointer has +/// exited hovering. +typedef TabValueChanged = void Function(T value, int index); + /// A Material Design primary tab bar. /// /// Primary tabs are placed at the top of the content pane under a top app bar. @@ -932,6 +941,8 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { this.mouseCursor, this.enableFeedback, this.onTap, + this.onHover, + this.onFocusChange, this.physics, this.splashFactory, this.splashBorderRadius, @@ -985,6 +996,8 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { this.mouseCursor, this.enableFeedback, this.onTap, + this.onHover, + this.onFocusChange, this.physics, this.splashFactory, this.splashBorderRadius, @@ -1253,6 +1266,46 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { /// interfere with the default tap handler. final ValueChanged? onTap; + /// An optional callback that's called when a [Tab]'s hover state in the + /// [TabBar] changes. + /// + /// Called when a pointer enters or exits the ink response area of the [Tab]. + /// + /// The value passed to the callback is true if a pointer has entered the + /// [Tab] at `index` and false if a pointer has exited. + /// + /// When hover is moved from one tab directly to another, this will be called + /// twice. First to represent hover exiting the initial tab, and then second + /// for the pointer entering hover over the next tab. + /// + /// {@tool dartpad} + /// This sample shows how to customize a [Tab] in response to hovering over a + /// [TabBar]. + /// + /// ** See code in examples/api/lib/material/tabs/tab_bar.onHover.dart ** + /// {@end-tool} + final TabValueChanged? onHover; + + /// An optional callback that's called when a [Tab]'s focus state in the + /// [TabBar] changes. + /// + /// Called when the node fo the [Tab] at `index` gains or loses focus. + /// + /// The value passed to the callback is true if the node has gained focus for + /// the [Tab] at `index` and false if focus has been lost. + /// + /// When focus is moved from one tab directly to another, this will be called + /// twice. First to represent focus being lost by the initially focused tab, + /// and then second for the next tab gaining focus. + /// + /// {@tool dartpad} + /// This sample shows how to customize a [Tab] based on focus traversal in + /// enclosing [TabBar]. + /// + /// ** See code in examples/api/lib/material/tabs/tab_bar.onFocusChange.dart ** + /// {@end-tool} + final TabValueChanged? onFocusChange; + /// How the [TabBar]'s scroll view should respond to user input. /// /// For example, determines how the scroll view continues to animate after the @@ -1895,6 +1948,12 @@ class _TabBarState extends State { onTap: () { _handleTap(index); }, + onHover: (bool value) { + widget.onHover?.call(value, index); + }, + onFocusChange: (bool value) { + widget.onFocusChange?.call(value, index); + }, enableFeedback: widget.enableFeedback ?? true, overlayColor: widget.overlayColor ?? tabBarTheme.overlayColor ?? defaultOverlay, splashFactory: widget.splashFactory ?? tabBarTheme.splashFactory ?? _defaults.splashFactory, diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index c935bd33c4..887e9d65db 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -8861,4 +8861,283 @@ void main() { ), ); }); + + testWidgets('onHover is triggered when mouse pointer is over a tab', (WidgetTester tester) async { + final List<({bool hover, int index})> hoverEvents = <({bool hover, int index})>[]; + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + onHover: (bool value, int index) { + hoverEvents.add((hover: value, index: index)); + }, + tabs: const [Tab(text: 'Tab 1'), Tab(text: 'Tab 2'), Tab(text: 'Tab 3')], + ), + ), + body: const TabBarView( + children: [Text('Tab 1 View'), Text('Tab 2 View'), Text('Tab 3 View')], + ), + ), + ), + ), + ); + + expect(hoverEvents.isEmpty, isTrue); + + // Hover over the first tab. + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text('Tab 1'))); + await tester.pump(); + + // Hover entered first tab. + expect(hoverEvents, <({bool hover, int index})>[(hover: true, index: 0)]); + + await gesture.moveTo(tester.getCenter(find.text('Tab 2'))); + await tester.pump(); + + expect(hoverEvents, <({bool hover, int index})>[ + (hover: true, index: 0), // First tab hover enter + (hover: false, index: 0), // First tab hover exit + (hover: true, index: 1), // Second tab hover enter + ]); + + await gesture.moveTo(tester.getCenter(find.text('Tab 3'))); + await tester.pump(); + + expect(hoverEvents, <({bool hover, int index})>[ + (hover: true, index: 0), // First tab hover enter + (hover: false, index: 0), // First tab hover exit + (hover: true, index: 1), // Second tab hover enter + (hover: false, index: 1), // Second tab hover exit + (hover: true, index: 2), // Third tab hover enter + ]); + + await gesture.moveTo(tester.getCenter(find.byType(TabBarView))); + await tester.pump(); + + expect(hoverEvents, <({bool hover, int index})>[ + (hover: true, index: 0), // First tab hover enter + (hover: false, index: 0), // First tab hover exit + (hover: true, index: 1), // Second tab hover enter + (hover: false, index: 1), // Second tab hover exit + (hover: true, index: 2), // Third tab hover enter + (hover: false, index: 2), // Third tab hover exit + ]); + + hoverEvents.clear(); + + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + bottom: TabBar.secondary( + onHover: (bool value, int index) { + hoverEvents.add((hover: value, index: index)); + }, + tabs: const [Tab(text: 'Tab 1'), Tab(text: 'Tab 2'), Tab(text: 'Tab 3')], + ), + ), + body: const TabBarView( + children: [Text('Tab 1 View'), Text('Tab 2 View'), Text('Tab 3 View')], + ), + ), + ), + ), + ); + + expect(hoverEvents.isEmpty, isTrue); + + // Hover over the first tab. + await gesture.moveTo(tester.getCenter(find.text('Tab 1'))); + await tester.pump(); + + // Hover enters first tab. + expect(hoverEvents, <({bool hover, int index})>[(hover: true, index: 0)]); + + await gesture.moveTo(tester.getCenter(find.text('Tab 2'))); + await tester.pump(); + + expect(hoverEvents, <({bool hover, int index})>[ + (hover: true, index: 0), // First tab hover enter + (hover: false, index: 0), // First tab hover exit + (hover: true, index: 1), // Second tab hover enter + ]); + + await gesture.moveTo(tester.getCenter(find.text('Tab 3'))); + await tester.pump(); + + expect(hoverEvents, <({bool hover, int index})>[ + (hover: true, index: 0), // First tab hover enter + (hover: false, index: 0), // First tab hover exit + (hover: true, index: 1), // Second tab hover enter + (hover: false, index: 1), // Second tab hover exit + (hover: true, index: 2), // Third tab hover enter + ]); + + await gesture.moveTo(tester.getCenter(find.byType(TabBarView))); + await tester.pump(); + + expect(hoverEvents, <({bool hover, int index})>[ + (hover: true, index: 0), // First tab hover enter + (hover: false, index: 0), // First tab hover exit + (hover: true, index: 1), // Second tab hover enter + (hover: false, index: 1), // Second tab hover exit + (hover: true, index: 2), // Third tab hover enter + (hover: false, index: 2), // Third tab hover exit + ]); + }); + + testWidgets('onFocusChange is triggered when tabs gain and lose focus', ( + WidgetTester tester, + ) async { + final List<({bool focus, int index})> focusEvents = <({bool focus, int index})>[]; + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + onFocusChange: (bool value, int index) { + focusEvents.add((focus: value, index: index)); + }, + tabs: const [Tab(text: 'Tab 1'), Tab(text: 'Tab 2'), Tab(text: 'Tab 3')], + ), + ), + body: const TabBarView( + children: [Text('Tab 1 View'), Text('Tab 2 View'), Text('Tab 3 View')], + ), + ), + ), + ), + ); + + expect(focusEvents.isEmpty, isTrue); + + // Focus on the first tab. + Element tabElement = tester.element(find.text('Tab 1')); + FocusNode node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + + // Focus gained at first tab. + expect(focusEvents, <({bool focus, int index})>[(focus: true, index: 0)]); + + tabElement = tester.element(find.text('Tab 2')); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + + expect(focusEvents, <({bool focus, int index})>[ + (focus: true, index: 0), // First tab gains focus + (focus: false, index: 0), // First tab loses focus + (focus: true, index: 1), // Second tab gains focus + ]); + + tabElement = tester.element(find.text('Tab 3')); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + expect(node.hasFocus, isTrue); + expect(focusEvents, <({bool focus, int index})>[ + (focus: true, index: 0), // First tab gains focus + (focus: false, index: 0), // First tab loses focus + (focus: true, index: 1), // Second tab gains focus + (focus: false, index: 1), // Second tab loses focus + (focus: true, index: 2), // Third tab gains focus + ]); + + node.unfocus(); + await tester.pump(); + + expect(node.hasFocus, isFalse); + expect(focusEvents, <({bool focus, int index})>[ + (focus: true, index: 0), // First tab gains focus + (focus: false, index: 0), // First tab loses focus + (focus: true, index: 1), // Second tab gains focus + (focus: false, index: 1), // Second tab loses focus + (focus: true, index: 2), // Third tab gains focus + (focus: false, index: 2), // Third tab loses focus + ]); + + focusEvents.clear(); + + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + bottom: TabBar.secondary( + onFocusChange: (bool value, int index) { + focusEvents.add((focus: value, index: index)); + }, + tabs: const [Tab(text: 'Tab 1'), Tab(text: 'Tab 2'), Tab(text: 'Tab 3')], + ), + ), + body: const TabBarView( + children: [Text('Tab 1 View'), Text('Tab 2 View'), Text('Tab 3 View')], + ), + ), + ), + ), + ); + + expect(focusEvents.isEmpty, isTrue); + + // Focus on the first tab. + tabElement = tester.element(find.text('Tab 1')); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + + // Focus gained at first tab. + expect(focusEvents, <({bool focus, int index})>[(focus: true, index: 0)]); + + tabElement = tester.element(find.text('Tab 2')); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + + expect(focusEvents, <({bool focus, int index})>[ + (focus: true, index: 0), // First tab gains focus + (focus: false, index: 0), // First tab loses focus + (focus: true, index: 1), // Second tab gains focus + ]); + + tabElement = tester.element(find.text('Tab 3')); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + expect(node.hasFocus, isTrue); + expect(focusEvents, <({bool focus, int index})>[ + (focus: true, index: 0), // First tab gains focus + (focus: false, index: 0), // First tab loses focus + (focus: true, index: 1), // Second tab gains focus + (focus: false, index: 1), // Second tab loses focus + (focus: true, index: 2), // Third tab gains focus + ]); + + node.unfocus(); + await tester.pump(); + + expect(node.hasFocus, isFalse); + expect(focusEvents, <({bool focus, int index})>[ + (focus: true, index: 0), // First tab gains focus + (focus: false, index: 0), // First tab loses focus + (focus: true, index: 1), // Second tab gains focus + (focus: false, index: 1), // Second tab loses focus + (focus: true, index: 2), // Third tab gains focus + (focus: false, index: 2), // Third tab loses focus + ]); + }); }