diff --git a/examples/api/lib/material/tabs/tab_bar.3.dart b/examples/api/lib/material/tabs/tab_bar.3.dart new file mode 100644 index 00000000000..bb54762d2c7 --- /dev/null +++ b/examples/api/lib/material/tabs/tab_bar.3.dart @@ -0,0 +1,192 @@ +// 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 a [TabBar] that displays custom effects on top of +/// the tab bar itself when there are more tabs in the scroll direction. + +void main() => runApp(const TabBarApp()); + +class TabBarApp extends StatelessWidget { + const TabBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: TabBarExample()); + } +} + +class TabBarExample extends StatefulWidget { + const TabBarExample({super.key}); + + @override + State createState() => _TabBarExampleState(); +} + +class _TabBarExampleState extends State { + double scrollOffset = 0; + double maxScrollExtent = 0; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 20, + child: Scaffold( + appBar: AppBar( + title: const Text('TabBar with scroll notifications'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(56.0), + child: NotificationListener( + onNotification: (Notification notification) { + // ScrollMetricsNotification is for initial layout. + // ScrollNotification is for real-time scroll updates. + final ScrollMetrics? metrics = switch (notification) { + ScrollMetricsNotification(:final metrics) => metrics, + ScrollNotification(:final metrics) => metrics, + _ => null, + }; + if (metrics != null) { + setState(() { + scrollOffset = metrics.pixels; + maxScrollExtent = metrics.maxScrollExtent; + }); + } + return false; + }, + child: Stack( + children: [ + TabBar( + isScrollable: true, + tabs: List.generate( + 20, + (int index) => Tab(text: 'Tab $index'), + ), + ), + // When the selected tab is not at the beginning or end + // (indicating TabBar is scrollable), add a gradient mask + // to left or right. + Positioned( + top: 0, + bottom: 0, + left: 0, + right: 0, + child: GradientMasks( + scrollOffset: scrollOffset, + maxScrollExtent: maxScrollExtent, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class GradientMasks extends StatelessWidget { + final double scrollOffset; + final double maxScrollExtent; + + const GradientMasks({ + super.key, + required this.scrollOffset, + required this.maxScrollExtent, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + if (scrollOffset > 0) const LeftMask(), + const Spacer(), + if (scrollOffset < maxScrollExtent) const RightMask(), + ], + ); + } +} + +/// This mask shows when the selected tab is not at the beginning. +class LeftMask extends StatelessWidget { + const LeftMask({super.key}); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: ClipRect( + child: BackdropFilter( + filter: ColorFilter.mode( + Colors.black.withValues(alpha: 0.2), + BlendMode.srcOver, + ), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.white.withValues(alpha: 0.8), + Colors.white.withValues(alpha: 0.2), + ], + ), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only(left: 4), + child: Icon( + Icons.chevron_left, + color: Colors.black.withValues(alpha: 0.4), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// This mask shows when the selected tab is not at the end. +class RightMask extends StatelessWidget { + const RightMask({super.key}); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: ClipRect( + child: BackdropFilter( + filter: ColorFilter.mode( + Colors.black.withValues(alpha: 0.2), + BlendMode.srcOver, + ), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerRight, + end: Alignment.centerLeft, + colors: [ + Colors.white.withValues(alpha: 0.8), + Colors.white.withValues(alpha: 0.2), + ], + ), + ), + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.only(right: 4), + child: Icon( + Icons.chevron_right, + color: Colors.black.withValues(alpha: 0.4), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/examples/api/test/material/tabs/tab_bar.3_test.dart b/examples/api/test/material/tabs/tab_bar.3_test.dart new file mode 100644 index 00000000000..cb8836dc98d --- /dev/null +++ b/examples/api/test/material/tabs/tab_bar.3_test.dart @@ -0,0 +1,59 @@ +// 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.3.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Expected mask displays when switching tabs in the TabBar', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(const example.TabBarApp()); + await tester.pump(); + + final TabBar primaryTabBar = tester.widget( + find.byType(TabBar).last, + ); + expect(primaryTabBar.tabs.length, 20); + + // In initialization, the first tab is selected, the right mask should be displayed. + String tabBarText = 'Tab 0'; + expect(find.text(tabBarText), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + + // Tap the last visible tab on screen. + final Finder lastVisibleTabFinder = find.byElementPredicate(( + Element element, + ) { + if (element.widget is! Tab) { + return false; + } + final RenderBox box = element.renderObject! as RenderBox; + final Offset center = box.localToGlobal(box.size.center(Offset.zero)); + return center.dx >= 0 && center.dx <= tester.view.physicalSize.width; + }).last; + + await tester.tap(lastVisibleTabFinder); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.chevron_left), findsOneWidget); + + // Jump to the end of the scrollable to verify the right mask is hidden. + final ScrollableState scrollable = tester.state( + find.byType(Scrollable).last, + ); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + await tester.pumpAndSettle(); + tabBarText = 'Tab 19'; + final Finder currentTab = find.text(tabBarText); + expect(currentTab, findsOneWidget); + + expect(find.byIcon(Icons.chevron_left), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsNothing); + }); +} diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index d04cb7f26ae..816b8aff8a2 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -950,6 +950,16 @@ typedef TabValueChanged = void Function(T value, int index); /// ** See code in examples/api/lib/material/tabs/tab_bar.2.dart ** /// {@end-tool} /// +/// {@tool dartpad} +/// This sample showcases how to apply custom behavior based on the scroll in [TabBar]. +/// It utilizes scroll notifications ([ScrollMetricsNotification] +/// and [ScrollNotification]) within [NotificationListener] callback +/// to monitor the scroll offset, allowing for interface customization +/// based on the obtained offset. +/// +/// ** See code in examples/api/lib/material/tabs/tab_bar.3.dart ** +/// {@end-tool} +/// /// See also: /// /// * [TabBar.secondary], for a secondary tab bar.