Unverified Commit a4b51233 authored by Markus Aksli's avatar Markus Aksli Committed by GitHub

Add `animationDuration` property to TabController (#91987)

parent 0d0b2dba
...@@ -101,11 +101,12 @@ class TabController extends ChangeNotifier { ...@@ -101,11 +101,12 @@ class TabController extends ChangeNotifier {
/// ///
/// The `initialIndex` must be valid given [length] and must not be null. If /// The `initialIndex` must be valid given [length] and must not be null. If
/// [length] is zero, then `initialIndex` must be 0 (the default). /// [length] is zero, then `initialIndex` must be 0 (the default).
TabController({ int initialIndex = 0, required this.length, required TickerProvider vsync }) TabController({ int initialIndex = 0, Duration? animationDuration, required this.length, required TickerProvider vsync})
: assert(length != null && length >= 0), : assert(length != null && length >= 0),
assert(initialIndex != null && initialIndex >= 0 && (length == 0 || initialIndex < length)), assert(initialIndex != null && initialIndex >= 0 && (length == 0 || initialIndex < length)),
_index = initialIndex, _index = initialIndex,
_previousIndex = initialIndex, _previousIndex = initialIndex,
_animationDuration = animationDuration ?? kTabScrollDuration,
_animationController = AnimationController.unbounded( _animationController = AnimationController.unbounded(
value: initialIndex.toDouble(), value: initialIndex.toDouble(),
vsync: vsync, vsync: vsync,
...@@ -117,14 +118,16 @@ class TabController extends ChangeNotifier { ...@@ -117,14 +118,16 @@ class TabController extends ChangeNotifier {
required int index, required int index,
required int previousIndex, required int previousIndex,
required AnimationController? animationController, required AnimationController? animationController,
required Duration animationDuration,
required this.length, required this.length,
}) : _index = index, }) : _index = index,
_previousIndex = previousIndex, _previousIndex = previousIndex,
_animationController = animationController; _animationController = animationController,
_animationDuration = animationDuration;
/// Creates a new [TabController] with `index`, `previousIndex`, and `length` /// Creates a new [TabController] with `index`, `previousIndex`, `length`, and
/// if they are non-null. /// `animationDuration` if they are non-null.
/// ///
/// This method is used by [DefaultTabController]. /// This method is used by [DefaultTabController].
/// ///
...@@ -134,6 +137,7 @@ class TabController extends ChangeNotifier { ...@@ -134,6 +137,7 @@ class TabController extends ChangeNotifier {
required int? index, required int? index,
required int? length, required int? length,
required int? previousIndex, required int? previousIndex,
required Duration? animationDuration,
}) { }) {
if (index != null) { if (index != null) {
_animationController!.value = index.toDouble(); _animationController!.value = index.toDouble();
...@@ -143,6 +147,7 @@ class TabController extends ChangeNotifier { ...@@ -143,6 +147,7 @@ class TabController extends ChangeNotifier {
length: length ?? this.length, length: length ?? this.length,
animationController: _animationController, animationController: _animationController,
previousIndex: previousIndex ?? _previousIndex, previousIndex: previousIndex ?? _previousIndex,
animationDuration: animationDuration ?? _animationDuration,
); );
} }
...@@ -159,6 +164,12 @@ class TabController extends ChangeNotifier { ...@@ -159,6 +164,12 @@ class TabController extends ChangeNotifier {
Animation<double>? get animation => _animationController?.view; Animation<double>? get animation => _animationController?.view;
AnimationController? _animationController; AnimationController? _animationController;
/// Controls the duration of TabController and TabBarView animations.
///
/// Defaults to kTabScrollDuration.
Duration get animationDuration => _animationDuration;
final Duration _animationDuration;
/// The total number of tabs. /// The total number of tabs.
/// ///
/// Typically greater than one. Must match [TabBar.tabs]'s and /// Typically greater than one. Must match [TabBar.tabs]'s and
...@@ -174,7 +185,7 @@ class TabController extends ChangeNotifier { ...@@ -174,7 +185,7 @@ class TabController extends ChangeNotifier {
return; return;
_previousIndex = index; _previousIndex = index;
_index = value; _index = value;
if (duration != null) { if (duration != null && duration > Duration.zero) {
_indexIsChangingCount += 1; _indexIsChangingCount += 1;
notifyListeners(); // Because the value of indexIsChanging may have changed. notifyListeners(); // Because the value of indexIsChanging may have changed.
_animationController! _animationController!
...@@ -228,8 +239,8 @@ class TabController extends ChangeNotifier { ...@@ -228,8 +239,8 @@ class TabController extends ChangeNotifier {
/// ///
/// While the animation is running [indexIsChanging] is true. When the /// While the animation is running [indexIsChanging] is true. When the
/// animation completes [offset] will be 0.0. /// animation completes [offset] will be 0.0.
void animateTo(int value, { Duration duration = kTabScrollDuration, Curve curve = Curves.ease }) { void animateTo(int value, { Duration? duration, Curve curve = Curves.ease }) {
_changeIndex(value, duration: duration, curve: curve); _changeIndex(value, duration: duration ?? _animationDuration, curve: curve);
} }
/// The difference between the [animation]'s value and [index]. /// The difference between the [animation]'s value and [index].
...@@ -333,6 +344,7 @@ class DefaultTabController extends StatefulWidget { ...@@ -333,6 +344,7 @@ class DefaultTabController extends StatefulWidget {
required this.length, required this.length,
this.initialIndex = 0, this.initialIndex = 0,
required this.child, required this.child,
this.animationDuration,
}) : assert(initialIndex != null), }) : assert(initialIndex != null),
assert(length >= 0), assert(length >= 0),
assert(length == 0 || (initialIndex >= 0 && initialIndex < length)), assert(length == 0 || (initialIndex >= 0 && initialIndex < length)),
...@@ -349,6 +361,11 @@ class DefaultTabController extends StatefulWidget { ...@@ -349,6 +361,11 @@ class DefaultTabController extends StatefulWidget {
/// Defaults to zero. /// Defaults to zero.
final int initialIndex; final int initialIndex;
/// Controls the duration of DefaultTabController and TabBarView animations.
///
/// Defaults to kTabScrollDuration.
final Duration? animationDuration;
/// The widget below this widget in the tree. /// The widget below this widget in the tree.
/// ///
/// Typically a [Scaffold] whose [AppBar] includes a [TabBar]. /// Typically a [Scaffold] whose [AppBar] includes a [TabBar].
...@@ -384,6 +401,7 @@ class _DefaultTabControllerState extends State<DefaultTabController> with Single ...@@ -384,6 +401,7 @@ class _DefaultTabControllerState extends State<DefaultTabController> with Single
vsync: this, vsync: this,
length: widget.length, length: widget.length,
initialIndex: widget.initialIndex, initialIndex: widget.initialIndex,
animationDuration: widget.animationDuration,
); );
} }
...@@ -416,9 +434,19 @@ class _DefaultTabControllerState extends State<DefaultTabController> with Single ...@@ -416,9 +434,19 @@ class _DefaultTabControllerState extends State<DefaultTabController> with Single
} }
_controller = _controller._copyWith( _controller = _controller._copyWith(
length: widget.length, length: widget.length,
animationDuration: widget.animationDuration,
index: newIndex, index: newIndex,
previousIndex: previousIndex, previousIndex: previousIndex,
); );
} }
if (oldWidget.animationDuration != widget.animationDuration) {
_controller = _controller._copyWith(
length: widget.length,
animationDuration: widget.animationDuration,
index: _controller.index,
previousIndex: _controller.previousIndex,
);
}
} }
} }
...@@ -1392,10 +1392,18 @@ class _TabBarViewState extends State<TabBarView> { ...@@ -1392,10 +1392,18 @@ class _TabBarViewState extends State<TabBarView> {
if (_pageController.page == _currentIndex!.toDouble()) if (_pageController.page == _currentIndex!.toDouble())
return Future<void>.value(); return Future<void>.value();
final Duration duration = _controller!.animationDuration;
if (duration == Duration.zero) {
_pageController.jumpToPage(_currentIndex!);
return Future<void>.value();
}
final int previousIndex = _controller!.previousIndex; final int previousIndex = _controller!.previousIndex;
if ((_currentIndex! - previousIndex).abs() == 1) { if ((_currentIndex! - previousIndex).abs() == 1) {
_warpUnderwayCount += 1; _warpUnderwayCount += 1;
await _pageController.animateToPage(_currentIndex!, duration: kTabScrollDuration, curve: Curves.ease); await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
_warpUnderwayCount -= 1; _warpUnderwayCount -= 1;
return Future<void>.value(); return Future<void>.value();
} }
...@@ -1415,7 +1423,7 @@ class _TabBarViewState extends State<TabBarView> { ...@@ -1415,7 +1423,7 @@ class _TabBarViewState extends State<TabBarView> {
}); });
_pageController.jumpToPage(initialPage); _pageController.jumpToPage(initialPage);
await _pageController.animateToPage(_currentIndex!, duration: kTabScrollDuration, curve: Curves.ease); await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
if (!mounted) if (!mounted)
return Future<void>.value(); return Future<void>.value();
setState(() { setState(() {
......
...@@ -109,9 +109,11 @@ Widget buildFrame({ ...@@ -109,9 +109,11 @@ Widget buildFrame({
required String value, required String value,
bool isScrollable = false, bool isScrollable = false,
Color? indicatorColor, Color? indicatorColor,
Duration? animationDuration,
}) { }) {
return boilerplate( return boilerplate(
child: DefaultTabController( child: DefaultTabController(
animationDuration: animationDuration,
initialIndex: tabs.indexOf(value), initialIndex: tabs.indexOf(value),
length: tabs.length, length: tabs.length,
child: TabBar( child: TabBar(
...@@ -937,6 +939,262 @@ void main() { ...@@ -937,6 +939,262 @@ void main() {
expect(find.text('Second'), findsNothing); expect(find.text('Second'), findsNothing);
}); });
testWidgets('TabBar animationDuration sets indicator animation duration', (WidgetTester tester) async {
const Duration animationDuration = Duration(milliseconds: 100);
final List<String> tabs = <String>['A', 'B', 'C'];
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', animationDuration: animationDuration));
final TabController controller = DefaultTabController.of(tester.element(find.text('A')))!;
await tester.tap(find.text('A'));
await tester.pump();
expect(controller.indexIsChanging, true);
await tester.pump(const Duration(milliseconds: 50));
await tester.pump(animationDuration);
expect(controller.index, 0);
expect(controller.previousIndex, 1);
expect(controller.indexIsChanging, false);
//Test when index diff is greater than 1
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', animationDuration: animationDuration));
await tester.tap(find.text('C'));
await tester.pump();
expect(controller.indexIsChanging, true);
await tester.pump(const Duration(milliseconds: 50));
await tester.pump(animationDuration);
expect(controller.index, 2);
expect(controller.previousIndex, 0);
expect(controller.indexIsChanging, false);
});
testWidgets('TabBarView controller sets animation duration', (WidgetTester tester) async {
const Duration animationDuration = Duration(milliseconds: 100);
final List<String> tabs = <String>['A', 'B', 'C'];
final TabController tabController = TabController(
vsync: const TestVSync(),
initialIndex: 1,
length: tabs.length,
animationDuration: animationDuration,
);
await tester.pumpWidget(boilerplate(
child: Column(
children: <Widget>[
TabBar(
tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(),
controller: tabController,
),
SizedBox(
width: 400.0,
height: 400.0,
child: TabBarView(
controller: tabController,
children: const <Widget>[
Center(child: Text('0')),
Center(child: Text('1')),
Center(child: Text('2')),
],
),
),
],
),
));
expect(tabController.index, 1);
final PageView pageView = tester.widget(find.byType(PageView));
final PageController pageController = pageView.controller;
final ScrollPosition position = pageController.position;
// The TabBarView's page width is 400, so page 0 is at scroll offset 0.0,
// page 1 is at 400.0, page 2 is at 800.0.
expect(position.pixels, 400);
await tester.tap(find.text('C'));
await tester.pump();
expect(position.pixels, 400);
await tester.pump(const Duration(milliseconds: 50));
await tester.pump(animationDuration);
expect(position.pixels, 800);
});
testWidgets('TabBar tap skips indicator animation when disabled in controller', (WidgetTester tester) async {
final List<String> tabs = <String>['A', 'B'];
const Color indicatorColor = Color(0xFFFF0000);
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'A', indicatorColor: indicatorColor, animationDuration: Duration.zero));
final RenderBox box = tester.renderObject(find.byType(TabBar));
final TabIndicatorRecordingCanvas canvas = TabIndicatorRecordingCanvas(indicatorColor);
final TestRecordingPaintingContext context = TestRecordingPaintingContext(canvas);
box.paint(context, Offset.zero);
final Rect indicatorRect0 = canvas.indicatorRect;
expect(indicatorRect0.left, 0.0);
expect(indicatorRect0.width, 400.0);
expect(indicatorRect0.height, 2.0);
await tester.tap(find.text('B'));
await tester.pump();
box.paint(context, Offset.zero);
final Rect indicatorRect2 = canvas.indicatorRect;
expect(indicatorRect2.left, 400.0);
expect(indicatorRect2.width, 400.0);
expect(indicatorRect2.height, 2.0);
});
testWidgets('TabBar tap changes index instantly when animation is disabled in controller', (WidgetTester tester) async {
final List<String> tabs = <String>['A', 'B', 'C'];
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', animationDuration: Duration.zero));
final TabController controller = DefaultTabController.of(tester.element(find.text('A')))!;
await tester.tap(find.text('A'));
await tester.pump();
expect(controller.index, 0);
expect(controller.previousIndex, 1);
expect(controller.indexIsChanging, false);
//Test when index diff is greater than 1
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', animationDuration: Duration.zero));
await tester.tap(find.text('C'));
await tester.pump();
expect(controller.index, 2);
expect(controller.previousIndex, 0);
expect(controller.indexIsChanging, false);
});
testWidgets('TabBarView skips animation when disabled in controller', (WidgetTester tester) async {
final List<String> tabs = <String>['A', 'B', 'C'];
final TabController tabController = TabController(
vsync: const TestVSync(),
initialIndex: 1,
length: tabs.length,
animationDuration: Duration.zero,
);
await tester.pumpWidget(boilerplate(
child: Column(
children: <Widget>[
TabBar(
tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(),
controller: tabController,
),
SizedBox(
width: 400.0,
height: 400.0,
child: TabBarView(
controller: tabController,
children: const <Widget>[
Center(child: Text('0')),
Center(child: Text('1')),
Center(child: Text('2')),
],
),
),
],
),
));
expect(tabController.index, 1);
final PageView pageView = tester.widget(find.byType(PageView));
final PageController pageController = pageView.controller;
final ScrollPosition position = pageController.position;
// The TabBarView's page width is 400, so page 0 is at scroll offset 0.0,
// page 1 is at 400.0, page 2 is at 800.0.
expect(position.pixels, 400);
await tester.tap(find.text('C'));
await tester.pump();
expect(position.pixels, 800);
});
testWidgets('TabBarView skips animation when disabled in controller - skip tabs', (WidgetTester tester) async {
final List<String> tabs = <String>['A', 'B', 'C'];
final TabController tabController = TabController(
vsync: const TestVSync(),
length: tabs.length,
animationDuration: Duration.zero,
);
await tester.pumpWidget(boilerplate(
child: Column(
children: <Widget>[
TabBar(
tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(),
controller: tabController,
),
SizedBox(
width: 400.0,
height: 400.0,
child: TabBarView(
controller: tabController,
children: const <Widget>[
Center(child: Text('0')),
Center(child: Text('1')),
Center(child: Text('2')),
],
),
),
],
),
));
expect(tabController.index, 0);
final PageView pageView = tester.widget(find.byType(PageView));
final PageController pageController = pageView.controller;
final ScrollPosition position = pageController.position;
// The TabBarView's page width is 400, so page 0 is at scroll offset 0.0,
// page 1 is at 400.0, page 2 is at 800.0.
expect(position.pixels, 0);
await tester.tap(find.text('C'));
await tester.pump();
expect(position.pixels, 800);
});
testWidgets('TabBarView skips animation when disabled in controller - two tabs', (WidgetTester tester) async {
final List<String> tabs = <String>['A', 'B'];
final TabController tabController = TabController(
vsync: const TestVSync(),
length: tabs.length,
animationDuration: Duration.zero,
);
await tester.pumpWidget(boilerplate(
child: Column(
children: <Widget>[
TabBar(
tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(),
controller: tabController,
),
SizedBox(
width: 400.0,
height: 400.0,
child: TabBarView(
controller: tabController,
children: const <Widget>[
Center(child: Text('0')),
Center(child: Text('1')),
],
),
),
],
),
));
expect(tabController.index, 0);
final PageView pageView = tester.widget(find.byType(PageView));
final PageController pageController = pageView.controller;
final ScrollPosition position = pageController.position;
// The TabBarView's page width is 400, so page 0 is at scroll offset 0.0,
// page 1 is at 400.0, page 2 is at 800.0.
expect(position.pixels, 0);
await tester.tap(find.text('B'));
await tester.pump();
expect(position.pixels, 400);
});
testWidgets('TabBar tap animates the selection indicator', (WidgetTester tester) async { testWidgets('TabBar tap animates the selection indicator', (WidgetTester tester) async {
// This is a regression test for https://github.com/flutter/flutter/issues/7479 // This is a regression test for https://github.com/flutter/flutter/issues/7479
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment