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 {
///
/// The `initialIndex` must be valid given [length] and must not be null. If
/// [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(initialIndex != null && initialIndex >= 0 && (length == 0 || initialIndex < length)),
_index = initialIndex,
_previousIndex = initialIndex,
_animationDuration = animationDuration ?? kTabScrollDuration,
_animationController = AnimationController.unbounded(
value: initialIndex.toDouble(),
vsync: vsync,
......@@ -117,14 +118,16 @@ class TabController extends ChangeNotifier {
required int index,
required int previousIndex,
required AnimationController? animationController,
required Duration animationDuration,
required this.length,
}) : _index = index,
_previousIndex = previousIndex,
_animationController = animationController;
_animationController = animationController,
_animationDuration = animationDuration;
/// Creates a new [TabController] with `index`, `previousIndex`, and `length`
/// if they are non-null.
/// Creates a new [TabController] with `index`, `previousIndex`, `length`, and
/// `animationDuration` if they are non-null.
///
/// This method is used by [DefaultTabController].
///
......@@ -134,6 +137,7 @@ class TabController extends ChangeNotifier {
required int? index,
required int? length,
required int? previousIndex,
required Duration? animationDuration,
}) {
if (index != null) {
_animationController!.value = index.toDouble();
......@@ -143,6 +147,7 @@ class TabController extends ChangeNotifier {
length: length ?? this.length,
animationController: _animationController,
previousIndex: previousIndex ?? _previousIndex,
animationDuration: animationDuration ?? _animationDuration,
);
}
......@@ -159,6 +164,12 @@ class TabController extends ChangeNotifier {
Animation<double>? get animation => _animationController?.view;
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.
///
/// Typically greater than one. Must match [TabBar.tabs]'s and
......@@ -174,7 +185,7 @@ class TabController extends ChangeNotifier {
return;
_previousIndex = index;
_index = value;
if (duration != null) {
if (duration != null && duration > Duration.zero) {
_indexIsChangingCount += 1;
notifyListeners(); // Because the value of indexIsChanging may have changed.
_animationController!
......@@ -228,8 +239,8 @@ class TabController extends ChangeNotifier {
///
/// While the animation is running [indexIsChanging] is true. When the
/// animation completes [offset] will be 0.0.
void animateTo(int value, { Duration duration = kTabScrollDuration, Curve curve = Curves.ease }) {
_changeIndex(value, duration: duration, curve: curve);
void animateTo(int value, { Duration? duration, Curve curve = Curves.ease }) {
_changeIndex(value, duration: duration ?? _animationDuration, curve: curve);
}
/// The difference between the [animation]'s value and [index].
......@@ -333,6 +344,7 @@ class DefaultTabController extends StatefulWidget {
required this.length,
this.initialIndex = 0,
required this.child,
this.animationDuration,
}) : assert(initialIndex != null),
assert(length >= 0),
assert(length == 0 || (initialIndex >= 0 && initialIndex < length)),
......@@ -349,6 +361,11 @@ class DefaultTabController extends StatefulWidget {
/// Defaults to zero.
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.
///
/// Typically a [Scaffold] whose [AppBar] includes a [TabBar].
......@@ -384,6 +401,7 @@ class _DefaultTabControllerState extends State<DefaultTabController> with Single
vsync: this,
length: widget.length,
initialIndex: widget.initialIndex,
animationDuration: widget.animationDuration,
);
}
......@@ -416,9 +434,19 @@ class _DefaultTabControllerState extends State<DefaultTabController> with Single
}
_controller = _controller._copyWith(
length: widget.length,
animationDuration: widget.animationDuration,
index: newIndex,
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> {
if (_pageController.page == _currentIndex!.toDouble())
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;
if ((_currentIndex! - previousIndex).abs() == 1) {
_warpUnderwayCount += 1;
await _pageController.animateToPage(_currentIndex!, duration: kTabScrollDuration, curve: Curves.ease);
await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
_warpUnderwayCount -= 1;
return Future<void>.value();
}
......@@ -1415,7 +1423,7 @@ class _TabBarViewState extends State<TabBarView> {
});
_pageController.jumpToPage(initialPage);
await _pageController.animateToPage(_currentIndex!, duration: kTabScrollDuration, curve: Curves.ease);
await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
if (!mounted)
return Future<void>.value();
setState(() {
......
......@@ -109,9 +109,11 @@ Widget buildFrame({
required String value,
bool isScrollable = false,
Color? indicatorColor,
Duration? animationDuration,
}) {
return boilerplate(
child: DefaultTabController(
animationDuration: animationDuration,
initialIndex: tabs.indexOf(value),
length: tabs.length,
child: TabBar(
......@@ -937,6 +939,262 @@ void main() {
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 {
// 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