Commit b23aed7a authored by Hans Muller's avatar Hans Muller Committed by GitHub

New Tabs API (#7387)

parent e82b18d4
......@@ -470,12 +470,12 @@ class ItemGalleryBox extends StatelessWidget {
return new SizedBox(
height: 200.0,
child: new TabBarSelection<String>(
values: tabNames,
child: new DefaultTabController(
length: tabNames.length,
child: new Column(
children: <Widget>[
new Expanded(
child: new TabBarView<String>(
child: new TabBarView(
children: tabNames.map((String tabName) {
return new Container(
key: new Key('Tab $index - $tabName'),
......@@ -521,7 +521,7 @@ class ItemGalleryBox extends StatelessWidget {
)
),
new Container(
child: new TabPageSelector<String>()
child: new TabPageSelector()
)
]
)
......
......@@ -412,8 +412,6 @@ class AnimationDemo extends StatefulWidget {
}
class _AnimationDemoState extends State<AnimationDemo> with TickerProviderStateMixin {
static final GlobalKey<TabBarSelectionState<_ArcDemo>> _tabsKey = new GlobalKey<TabBarSelectionState<_ArcDemo>>();
List<_ArcDemo> _allDemos;
@override
......@@ -435,8 +433,7 @@ class _AnimationDemoState extends State<AnimationDemo> with TickerProviderStateM
];
}
Future<Null> _play() async {
_ArcDemo demo = _tabsKey.currentState.value;
Future<Null> _play(_ArcDemo demo) async {
await demo.controller.forward();
if (demo.key.currentState != null && demo.key.currentState.mounted)
demo.controller.reverse();
......@@ -444,23 +441,26 @@ class _AnimationDemoState extends State<AnimationDemo> with TickerProviderStateM
@override
Widget build(BuildContext context) {
return new TabBarSelection<_ArcDemo>(
key: _tabsKey,
values: _allDemos,
return new DefaultTabController(
length: _allDemos.length,
child: new Scaffold(
appBar: new AppBar(
title: new Text('Animation'),
bottom: new TabBar<_ArcDemo>(
labels: new Map<_ArcDemo, TabLabel>.fromIterable(_allDemos, value: (_ArcDemo demo) {
return new TabLabel(text: demo.title);
})
)
bottom: new TabBar(
tabs: _allDemos.map((_ArcDemo demo) => new Tab(text: demo.title)).toList(),
),
),
floatingActionButton: new FloatingActionButton(
onPressed: _play,
child: new Icon(Icons.refresh)
floatingActionButton: new Builder(
builder: (BuildContext context) {
return new FloatingActionButton(
child: new Icon(Icons.refresh),
onPressed: () {
_play(_allDemos[DefaultTabController.of(context).index]);
},
);
},
),
body: new TabBarView<_ArcDemo>(
body: new TabBarView(
children: _allDemos.map((_ArcDemo demo) => demo.builder(demo)).toList()
)
)
......
......@@ -107,38 +107,28 @@ class ColorSwatchTabView extends StatelessWidget {
}
}
class ColorsDemo extends StatefulWidget {
ColorsDemo({ Key key }) : super(key: key);
class ColorsDemo extends StatelessWidget {
static const String routeName = '/colors';
@override
_ColorsDemoState createState() => new _ColorsDemoState();
}
class _ColorsDemoState extends State<ColorsDemo> {
@override
Widget build(BuildContext context) {
return new TabBarSelection<ColorSwatch>(
values: colorSwatches,
return new DefaultTabController(
length: colorSwatches.length,
child: new Scaffold(
appBar: new AppBar(
elevation: 0,
title: new Text('Colors'),
bottom: new TabBar<ColorSwatch>(
bottom: new TabBar(
isScrollable: true,
labels: new Map<ColorSwatch, TabLabel>.fromIterable(colorSwatches, value: (ColorSwatch swatch) {
return new TabLabel(text: swatch.name);
})
tabs: colorSwatches.map((ColorSwatch swatch) => new Tab(text: swatch.name)).toList(),
)
),
body: new TabBarView<ColorSwatch>(
body: new TabBarView(
children: colorSwatches.map((ColorSwatch swatch) {
return new ColorSwatchTabView(swatch: swatch);
})
.toList()
)
)
}).toList(),
),
),
);
}
}
......@@ -4,78 +4,83 @@
import 'package:flutter/material.dart';
class PageSelectorDemo extends StatelessWidget {
class _PageSelector extends StatelessWidget {
_PageSelector({ this.icons });
static const String routeName = '/page-selector';
final List<IconData> icons;
void _handleArrowButtonPress(BuildContext context, int delta) {
final TabBarSelectionState<IconData> selection = TabBarSelection.of/*<IconData>*/(context);
if (!selection.valueIsChanging)
selection.value = selection.values[(selection.index + delta).clamp(0, selection.values.length - 1)];
TabController controller = DefaultTabController.of(context);
if (!controller.indexIsChanging)
controller.animateTo(controller.index + delta);
}
@override
Widget build(BuildContext notUsed) { // Can't find the TabBarSelection from this context.
final List<IconData> icons = <IconData>[
Icons.event,
Icons.home,
Icons.android,
Icons.alarm,
Icons.face,
Icons.language,
];
Widget build(BuildContext context) {
final TabController controller = DefaultTabController.of(context);
final Color color = Theme.of(context).accentColor;
return new Column(
children: <Widget>[
new Container(
margin: const EdgeInsets.only(top: 16.0),
child: new Row(
children: <Widget>[
new IconButton(
icon: new Icon(Icons.chevron_left),
color: color,
onPressed: () { _handleArrowButtonPress(context, -1); },
tooltip: 'Page back'
),
new TabPageSelector(controller: controller),
new IconButton(
icon: new Icon(Icons.chevron_right),
color: color,
onPressed: () { _handleArrowButtonPress(context, 1); },
tooltip: 'Page forward'
)
],
mainAxisAlignment: MainAxisAlignment.spaceBetween
)
),
new Expanded(
child: new TabBarView(
children: icons.map((IconData icon) {
return new Container(
key: new ObjectKey(icon),
padding: const EdgeInsets.all(12.0),
child: new Card(
child: new Center(
child: new Icon(icon, size: 128.0, color: color)
),
),
);
}).toList()
),
),
],
);
}
}
class PageSelectorDemo extends StatelessWidget {
static const String routeName = '/page-selector';
static final List<IconData> icons = <IconData>[
Icons.event,
Icons.home,
Icons.android,
Icons.alarm,
Icons.face,
Icons.language,
];
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(title: new Text('Page selector')),
body: new TabBarSelection<IconData>(
values: icons,
child: new Builder(
builder: (BuildContext context) {
final Color color = Theme.of(context).accentColor;
return new Column(
children: <Widget>[
new Container(
margin: const EdgeInsets.only(top: 16.0),
child: new Row(
children: <Widget>[
new IconButton(
icon: new Icon(Icons.chevron_left),
color: color,
onPressed: () { _handleArrowButtonPress(context, -1); },
tooltip: 'Page back'
),
new TabPageSelector<IconData>(),
new IconButton(
icon: new Icon(Icons.chevron_right),
color: color,
onPressed: () { _handleArrowButtonPress(context, 1); },
tooltip: 'Page forward'
)
],
mainAxisAlignment: MainAxisAlignment.spaceBetween
)
),
new Expanded(
child: new TabBarView<IconData>(
children: icons.map((IconData icon) {
return new Container(
key: new ObjectKey(icon),
padding: const EdgeInsets.all(12.0),
child: new Card(
child: new Center(
child: new Icon(icon, size: 128.0, color: color)
)
)
);
})
.toList()
)
)
]
);
}
)
)
body: new DefaultTabController(
length: icons.length,
child: new _PageSelector(icons: icons),
),
);
}
}
......@@ -10,6 +10,21 @@ enum TabsDemoStyle {
textOnly
}
class _Page {
_Page({ this.icon, this.text });
final IconData icon;
final String text;
}
final List<_Page> _allPages = <_Page>[
new _Page(icon: Icons.event, text: 'EVENT'),
new _Page(icon: Icons.home, text: 'HOME'),
new _Page(icon: Icons.android, text: 'ANDROID'),
new _Page(icon: Icons.alarm, text: 'ALARM'),
new _Page(icon: Icons.face, text: 'FACE'),
new _Page(icon: Icons.language, text: 'LANGAUGE'),
];
class ScrollableTabsDemo extends StatefulWidget {
static const String routeName = '/scrollable-tabs';
......@@ -17,26 +32,21 @@ class ScrollableTabsDemo extends StatefulWidget {
ScrollableTabsDemoState createState() => new ScrollableTabsDemoState();
}
class ScrollableTabsDemoState extends State<ScrollableTabsDemo> {
final List<IconData> icons = <IconData>[
Icons.event,
Icons.home,
Icons.android,
Icons.alarm,
Icons.face,
Icons.language,
];
class ScrollableTabsDemoState extends State<ScrollableTabsDemo> with SingleTickerProviderStateMixin {
TabController _controller;
TabsDemoStyle _demoStyle = TabsDemoStyle.iconsAndText;
final Map<IconData, String> labels = <IconData, String>{
Icons.event: 'EVENT',
Icons.home: 'HOME',
Icons.android: 'ANDROID',
Icons.alarm: 'ALARM',
Icons.face: 'FACE',
Icons.language: 'LANGUAGE',
};
@override
void initState() {
super.initState();
_controller = new TabController(vsync: this, length: _allPages.length);
}
TabsDemoStyle _demoStyle = TabsDemoStyle.iconsAndText;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void changeDemoStyle(TabsDemoStyle style) {
setState(() {
......@@ -47,65 +57,61 @@ class ScrollableTabsDemoState extends State<ScrollableTabsDemo> {
@override
Widget build(BuildContext context) {
final Color iconColor = Theme.of(context).accentColor;
return new TabBarSelection<IconData>(
values: icons,
child: new Scaffold(
appBar: new AppBar(
title: new Text('Scrollable tabs'),
actions: <Widget>[
new PopupMenuButton<TabsDemoStyle>(
onSelected: changeDemoStyle,
itemBuilder: (BuildContext context) => <PopupMenuItem<TabsDemoStyle>>[
new PopupMenuItem<TabsDemoStyle>(
value: TabsDemoStyle.iconsAndText,
child: new Text('Icons and text')
),
new PopupMenuItem<TabsDemoStyle>(
value: TabsDemoStyle.iconsOnly,
child: new Text('Icons only')
),
new PopupMenuItem<TabsDemoStyle>(
value: TabsDemoStyle.textOnly,
child: new Text('Text only')
),
]
)
],
bottom: new TabBar<IconData>(
isScrollable: true,
labels: new Map<IconData, TabLabel>.fromIterable(
icons,
value: (IconData icon) {
switch(_demoStyle) {
case TabsDemoStyle.iconsAndText:
return new TabLabel(text: labels[icon], icon: new Icon(icon));
case TabsDemoStyle.iconsOnly:
return new TabLabel(icon: new Icon(icon));
case TabsDemoStyle.textOnly:
return new TabLabel(text: labels[icon]);
}
}
)
)
return new Scaffold(
appBar: new AppBar(
title: new Text('Scrollable tabs'),
actions: <Widget>[
new PopupMenuButton<TabsDemoStyle>(
onSelected: changeDemoStyle,
itemBuilder: (BuildContext context) => <PopupMenuItem<TabsDemoStyle>>[
new PopupMenuItem<TabsDemoStyle>(
value: TabsDemoStyle.iconsAndText,
child: new Text('Icons and text')
),
new PopupMenuItem<TabsDemoStyle>(
value: TabsDemoStyle.iconsOnly,
child: new Text('Icons only')
),
new PopupMenuItem<TabsDemoStyle>(
value: TabsDemoStyle.textOnly,
child: new Text('Text only')
),
],
),
],
bottom: new TabBar(
controller: _controller,
isScrollable: true,
tabs: _allPages.map((_Page page) {
switch(_demoStyle) {
case TabsDemoStyle.iconsAndText:
return new Tab(text: page.text, icon: new Icon(page.icon));
case TabsDemoStyle.iconsOnly:
return new Tab(icon: new Icon(page.icon));
case TabsDemoStyle.textOnly:
return new Tab(text: page.text);
}
}).toList(),
),
body: new TabBarView<IconData>(
children: icons.map((IconData icon) {
return new Container(
key: new ObjectKey(icon),
padding: const EdgeInsets.all(12.0),
child:new Card(
child: new Center(
child: new Icon(
icon,
color: iconColor,
size: 128.0
)
)
)
);
}).toList()
)
)
),
body: new TabBarView(
controller: _controller,
children: _allPages.map((_Page page) {
return new Container(
key: new ObjectKey(page.icon),
padding: const EdgeInsets.all(12.0),
child:new Card(
child: new Center(
child: new Icon(
page.icon,
color: iconColor,
size: 128.0,
),
),
),
);
}).toList()
),
);
}
}
......@@ -111,30 +111,21 @@ class _CardDataItem extends StatelessWidget {
}
}
class TabsDemo extends StatefulWidget {
TabsDemo({ Key key }) : super(key: key);
class TabsDemo extends StatelessWidget {
static const String routeName = '/tabs';
@override
_TabsDemoState createState() => new _TabsDemoState();
}
class _TabsDemoState extends State<TabsDemo> {
@override
Widget build(BuildContext context) {
return new TabBarSelection<_Page>(
values: _allPages.keys.toList(),
return new DefaultTabController(
length: _allPages.length,
child: new Scaffold(
appBar: new AppBar(
title: new Text('Tabs and scrolling'),
bottom: new TabBar<_Page>(
labels: new Map<_Page, TabLabel>.fromIterable(_allPages.keys, value: (_Page page) {
return new TabLabel(text: page.label);
})
)
bottom: new TabBar(
tabs: _allPages.keys.map((_Page page) => new Tab(text: page.label)).toList(),
),
),
body: new TabBarView<_Page>(
body: new TabBarView(
children: _allPages.keys.map((_Page page) {
return new ScrollableList(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
......@@ -144,11 +135,11 @@ class _TabsDemoState extends State<TabsDemo> {
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: new _CardDataItem(page: page, data: data)
);
}).toList()
}).toList(),
);
}).toList()
)
)
}).toList(),
),
),
);
}
}
......@@ -4,6 +4,12 @@
import 'package:flutter/material.dart';
const String _explanatoryText =
"When the Scaffold's floating action button changes, the new button fades and "
"turns into view. In this demo, changing tabs can cause the app to be rebuilt "
"with a FloatingActionButton that the Scaffold distinguishes from the others "
"by its key.";
class _Page {
_Page({ this.label, this.colors, this.icon });
......@@ -11,7 +17,6 @@ class _Page {
final Map<int, Color> colors;
final IconData icon;
TabLabel get tabLabel => new TabLabel(text: label.toUpperCase());
Color get labelColor => colors != null ? colors[300] : Colors.grey[300];
bool get fabDefined => colors != null && icon != null;
Color get fabColor => colors[400];
......@@ -19,11 +24,13 @@ class _Page {
Key get fabKey => new ValueKey<Color>(fabColor);
}
const String _explanatoryText =
"When the Scaffold's floating action button changes, the new button fades and "
"turns into view. In this demo, changing tabs can cause the app to be rebuilt "
"with a FloatingActionButton that the Scaffold distinguishes from the others "
"by its key.";
final List<_Page> _allPages = <_Page>[
new _Page(label: 'Blue', colors: Colors.indigo, icon: Icons.add),
new _Page(label: 'Eco', colors: Colors.green, icon: Icons.create),
new _Page(label: 'No'),
new _Page(label: 'Teal', colors: Colors.teal, icon: Icons.add),
new _Page(label: 'Red', colors: Colors.red, icon: Icons.create),
];
class TabsFabDemo extends StatefulWidget {
static const String routeName = '/tabs-fab';
......@@ -32,31 +39,34 @@ class TabsFabDemo extends StatefulWidget {
_TabsFabDemoState createState() => new _TabsFabDemoState();
}
class _TabsFabDemoState extends State<TabsFabDemo> {
final GlobalKey<ScaffoldState> scaffoldKey = new GlobalKey<ScaffoldState>();
final List<_Page> pages = <_Page>[
new _Page(label: 'Blue', colors: Colors.indigo, icon: Icons.add),
new _Page(label: 'Eco', colors: Colors.green, icon: Icons.create),
new _Page(label: 'No'),
new _Page(label: 'Teal', colors: Colors.teal, icon: Icons.add),
new _Page(label: 'Red', colors: Colors.red, icon: Icons.create),
];
_Page selectedPage;
class _TabsFabDemoState extends State<TabsFabDemo> with SingleTickerProviderStateMixin {
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
TabController _controller;
_Page _selectedPage;
@override
void initState() {
super.initState();
selectedPage = pages[0];
_controller = new TabController(vsync: this, length: _allPages.length);
_controller.addListener(_handleTabSelection);
_selectedPage = _allPages[0];
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTabSelection(_Page page) {
void _handleTabSelection() {
setState(() {
selectedPage = page;
_selectedPage = _allPages[_controller.index];
});
}
void _showExplanatoryText() {
scaffoldKey.currentState.showBottomSheet((BuildContext context) {
_scaffoldKey.currentState.showBottomSheet((BuildContext context) {
return new Container(
decoration: new BoxDecoration(
border: new Border(top: new BorderSide(color: Theme.of(context).dividerColor))
......@@ -93,26 +103,26 @@ class _TabsFabDemoState extends State<TabsFabDemo> {
@override
Widget build(BuildContext context) {
return new TabBarSelection<_Page>(
values: pages,
onChanged: _handleTabSelection,
child: new Scaffold(
key: scaffoldKey,
appBar: new AppBar(
title: new Text('FAB per tab'),
bottom: new TabBar<_Page>(
labels: new Map<_Page, TabLabel>.fromIterable(pages, value: (_Page page) => page.tabLabel)
)
),
floatingActionButton: !selectedPage.fabDefined ? null : new FloatingActionButton(
key: selectedPage.fabKey,
tooltip: 'Show explanation',
backgroundColor: selectedPage.fabColor,
child: selectedPage.fabIcon,
onPressed: _showExplanatoryText
),
body: new TabBarView<_Page>(children: pages.map(buildTabView).toList())
)
return new Scaffold(
key: _scaffoldKey,
appBar: new AppBar(
title: new Text('FAB per tab'),
bottom: new TabBar(
controller: _controller,
tabs: _allPages.map((_Page page) => new Tab(text: page.label.toUpperCase())).toList(),
)
),
floatingActionButton: !_selectedPage.fabDefined ? null : new FloatingActionButton(
key: _selectedPage.fabKey,
tooltip: 'Show explanation',
backgroundColor: _selectedPage.fabColor,
child: _selectedPage.fabIcon,
onPressed: _showExplanatoryText
),
body: new TabBarView(
controller: _controller,
children: _allPages.map(buildTabView).toList()
),
);
}
}
......@@ -20,13 +20,6 @@ class ComponentDemoTabData {
final String description;
final String tabName;
static Map<ComponentDemoTabData, TabLabel> buildTabLabels(List<ComponentDemoTabData> demos) {
return new Map<ComponentDemoTabData, TabLabel>.fromIterable(
demos,
value: (ComponentDemoTabData demo) => new TabLabel(text: demo.tabName)
);
}
@override
bool operator==(Object other) {
if (other.runtimeType != runtimeType)
......@@ -49,8 +42,7 @@ class TabbedComponentDemoScaffold extends StatelessWidget {
final String title;
void _showExampleCode(BuildContext context) {
TabBarSelectionState<ComponentDemoTabData> selection = TabBarSelection.of(context);
String tag = selection.value?.exampleCodeTag;
String tag = demos[DefaultTabController.of(context).index].exampleCodeTag;
if (tag != null) {
Navigator.push(context, new MaterialPageRoute<FullScreenCodeDialog>(
builder: (BuildContext context) => new FullScreenCodeDialog(exampleCodeTag: tag)
......@@ -60,8 +52,8 @@ class TabbedComponentDemoScaffold extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new TabBarSelection<ComponentDemoTabData>(
values: demos,
return new DefaultTabController(
length: demos.length,
child: new Scaffold(
appBar: new AppBar(
title: new Text(title),
......@@ -71,17 +63,19 @@ class TabbedComponentDemoScaffold extends StatelessWidget {
return new IconButton(
icon: new Icon(Icons.description),
tooltip: 'Show example code',
onPressed: () { _showExampleCode(context); }
onPressed: () {
_showExampleCode(context);
},
);
}
)
},
),
],
bottom: new TabBar<ComponentDemoTabData>(
bottom: new TabBar(
isScrollable: true,
labels: ComponentDemoTabData.buildTabLabels(demos)
)
tabs: demos.map((ComponentDemoTabData data) => new Tab(text: data.tabName)).toList(),
),
),
body: new TabBarView<ComponentDemoTabData>(
body: new TabBarView(
children: demos.map((ComponentDemoTabData demo) {
return new Column(
children: <Widget>[
......@@ -92,11 +86,11 @@ class TabbedComponentDemoScaffold extends StatelessWidget {
)
),
new Expanded(child: demo.widget)
]
],
);
}).toList()
)
)
}).toList(),
),
),
);
}
}
......
......@@ -222,11 +222,11 @@ class StockHomeState extends State<StockHome> {
]
)
],
bottom: new TabBar<StockHomeTab>(
labels: <StockHomeTab, TabLabel>{
StockHomeTab.market: new TabLabel(text: StockStrings.of(context).market()),
StockHomeTab.portfolio: new TabLabel(text: StockStrings.of(context).portfolio())
}
bottom: new TabBar(
tabs: <Widget>[
new Tab(text: StockStrings.of(context).market()),
new Tab(text: StockStrings.of(context).portfolio()),
]
)
);
}
......@@ -318,14 +318,14 @@ class StockHomeState extends State<StockHome> {
@override
Widget build(BuildContext context) {
return new TabBarSelection<StockHomeTab>(
values: <StockHomeTab>[StockHomeTab.market, StockHomeTab.portfolio],
return new DefaultTabController(
length: 2,
child: new Scaffold(
key: _scaffoldKey,
appBar: _isSearching ? buildSearchBar() : buildAppBar(),
floatingActionButton: buildFloatingActionButton(),
drawer: _buildDrawer(context),
body: new TabBarView<StockHomeTab>(
body: new TabBarView(
children: <Widget>[
_buildStockTab(context, StockHomeTab.market, config.symbols),
_buildStockTab(context, StockHomeTab.portfolio, portfolioSymbols),
......
......@@ -71,6 +71,7 @@ export 'src/material/snack_bar.dart';
export 'src/material/stepper.dart';
export 'src/material/switch.dart';
export 'src/material/tabs.dart';
export 'src/material/tab_controller.dart';
export 'src/material/theme.dart';
export 'src/material/theme_data.dart';
export 'src/material/time_picker.dart';
......
......@@ -22,3 +22,6 @@ const Duration kRadialReactionDuration = const Duration(milliseconds: 200);
/// The value of the alpha channel to use when drawing a circular material ink response.
const int kRadialReactionAlpha = 0x33;
/// The duration
const Duration kTabScrollDuration = const Duration(milliseconds: 200);
// Copyright 2015 The Chromium 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/foundation.dart';
import 'package:flutter/widgets.dart';
import 'constants.dart';
/// Coordinates tab selection between a [TabBar] and a [TabBarView].
///
/// The [index] property is the index of the selected tab and the [animation]
/// represents the current scroll positions of the tab bar and the tar bar view.
/// The selected tab's index can be changed with [animateTo].
///
/// See also:
///
/// * [DefaultTabController], which simplifies sharing a TabController with
/// its [TabBar] and a [TabBarView] descendants.
class TabController extends ChangeNotifier {
/// Creates an object that manages the state required by [TabBar] and a [TabBarView].
TabController({ int initialIndex: 0, @required this.length, @required TickerProvider vsync })
: _index = initialIndex,
_previousIndex = initialIndex,
_animationController = new AnimationController(
value: initialIndex.toDouble(),
upperBound: (length - 1).toDouble(),
vsync: vsync
) {
assert(length != null && length > 1);
assert(initialIndex != null && initialIndex >= 0 && initialIndex < length);
}
/// An animation whose value represents the current position of the [TabBar]'s
/// selected tab indicator as well as the scrollOffsets of the [TabBar]
/// and [TabBarView].
///
/// The animation's value ranges from 0.0 to [length] - 1.0. After the
/// selected tab is changed, the animation's value equals [index]. The
/// animation's value can be [offset] by +/- 1.0 to reflect [TabBarView]
/// drag scrolling.
final AnimationController _animationController;
Animation<double> get animation => _animationController.view;
/// The total number of tabs. Must be greater than one.
final int length;
void _changeIndex(int value, { Duration duration, Curve curve }) {
assert(value != null);
assert(value >= 0 && value < length);
assert(duration == null ? curve == null : true);
assert(_indexIsChangingCount >= 0);
if (value == _index)
return;
_previousIndex = index;
_index = value;
if (duration != null) {
_indexIsChangingCount += 1;
_animationController
..animateTo(_index.toDouble(), duration: duration, curve: curve).then((_) {
_indexIsChangingCount -= 1;
notifyListeners();
});
} else {
_indexIsChangingCount += 1;
_animationController.value = _index.toDouble();
_indexIsChangingCount -= 1;
notifyListeners();
}
}
/// The index of the currently selected tab. Changing the index also updates
/// [previousIndex], sets the [animation]'s value to index, resets
/// [indexIsChanging] to false, and notifies listeners.
///
/// To change the currently selected tab and play the [animation] use [animateTo].
int get index => _index;
int _index;
set index(int value) {
_changeIndex(value);
}
/// The index of the previously selected tab. Initially the same as [index].
int get previousIndex => _previousIndex;
int _previousIndex;
/// True while we're animating from [previousIndex] to [index].
bool get indexIsChanging => _indexIsChangingCount != 0;
int _indexIsChangingCount = 0;
/// Immediately sets [index] and [previousIndex] and then plays the
/// [animation] from its current value to [index].
///
/// 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);
}
/// The difference between the [animation]'s value and [index]. The offset
/// value must be between -1.0 and 1.0.
///
/// This property is typically set by the [TabBarView] when the user
/// drags left or right. A value between -1.0 and 0.0 implies that the
/// TabBarView has been dragged to the left. Similarly a value between
/// 0.0 and 1.0 implies that the TabBarView has been dragged to the right.
double get offset => _animationController.value - _index.toDouble();
set offset(double newOffset) {
assert(newOffset != null);
assert(newOffset >= -1.0 && newOffset <= 1.0);
assert(!indexIsChanging);
if (newOffset == offset)
return;
_animationController.value = newOffset + _index.toDouble();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
}
class _TabControllerScope extends InheritedWidget {
_TabControllerScope({
Key key,
this.controller,
this.enabled,
Widget child
}) : super(key: key, child: child);
final TabController controller;
final bool enabled;
@override
bool updateShouldNotify(_TabControllerScope old) {
return enabled != old.enabled || controller != old.controller;
}
}
/// The [TabController] for descendant widgets that don't specify one explicitly.
class DefaultTabController extends StatefulWidget {
DefaultTabController({
Key key,
@required this.length,
this.initialIndex: 0,
this.child
}) : super(key: key);
/// The total number of tabs. Must be greater than one.
final int length;
/// The initial index of the selected tab.
final int initialIndex;
/// This widget's child. Often a [Scaffold] whose [AppBar] includes a [TabBar].
final Widget child;
/// The closest instance of this class that encloses the given context.
///
/// Typical usage:
///
/// ```dart
/// TabController controller = DefaultTabBarController.of(context);
/// ```
static TabController of(BuildContext context) {
_TabControllerScope scope = context.inheritFromWidgetOfExactType(_TabControllerScope);
return scope?.controller;
}
@override
_DefaultTabControllerState createState() => new _DefaultTabControllerState();
}
class _DefaultTabControllerState extends State<DefaultTabController> with SingleTickerProviderStateMixin {
TabController _controller;
@override
void initState() {
super.initState();
_controller = new TabController(
vsync: this,
length: config.length,
initialIndex: config.initialIndex,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return new _TabControllerScope(
controller: _controller,
enabled: TickerMode.of(context),
child: config.child,
);
}
}
......@@ -3,396 +3,80 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' show lerpDouble;
import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'app_bar.dart';
import 'colors.dart';
import 'constants.dart';
import 'debug.dart';
import 'icon.dart';
import 'icon_theme.dart';
import 'icon_theme_data.dart';
import 'ink_well.dart';
import 'material.dart';
import 'tab_controller.dart';
import 'theme.dart';
typedef void _TabLayoutChanged(Size size, List<double> widths);
// See https://material.google.com/components/tabs.html#tabs-specs
const double _kTabHeight = 46.0;
const double _kTextAndIconTabHeight = 72.0;
const double _kTabIndicatorHeight = 2.0;
const double _kMinTabWidth = 72.0;
const double _kMaxTabWidth = 264.0;
const EdgeInsets _kTabLabelPadding = const EdgeInsets.symmetric(horizontal: 12.0);
const double _kTabBarScrollDrag = 0.025;
const Duration _kTabBarScroll = const Duration(milliseconds: 200);
// Curves for the leading and trailing edge of the selected tab indicator.
const Curve _kTabIndicatorLeadingCurve = Curves.easeOut;
const Curve _kTabIndicatorTrailingCurve = Curves.easeIn;
// The additional factor of 5 is to further increase sensitivity to swipe
// gestures and was determined "experimentally".
final double _kMinFlingVelocity = kPixelScrollTolerance.velocity / 5.0;
class _TabBarParentData extends ContainerBoxParentDataMixin<RenderBox> { }
class _RenderTabBar extends RenderBox with
ContainerRenderObjectMixin<RenderBox, _TabBarParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _TabBarParentData> {
_RenderTabBar(this.onLayoutChanged);
int _selectedIndex;
int get selectedIndex => _selectedIndex;
set selectedIndex(int value) {
if (_selectedIndex != value) {
_selectedIndex = value;
markNeedsPaint();
}
}
Color _indicatorColor;
Color get indicatorColor => _indicatorColor;
set indicatorColor(Color value) {
if (_indicatorColor != value) {
_indicatorColor = value;
markNeedsPaint();
}
}
Rect _indicatorRect;
Rect get indicatorRect => _indicatorRect;
set indicatorRect(Rect value) {
if (_indicatorRect != value) {
_indicatorRect = value;
markNeedsPaint();
}
}
bool _textAndIcons;
bool get textAndIcons => _textAndIcons;
set textAndIcons(bool value) {
if (_textAndIcons != value) {
_textAndIcons = value;
markNeedsLayout();
}
}
bool _isScrollable;
bool get isScrollable => _isScrollable;
set isScrollable(bool value) {
if (_isScrollable != value) {
_isScrollable = value;
markNeedsLayout();
}
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _TabBarParentData)
child.parentData = new _TabBarParentData();
}
@override
double computeMinIntrinsicWidth(double height) {
double maxWidth = 0.0;
RenderBox child = firstChild;
while (child != null) {
maxWidth = math.max(maxWidth, child.getMinIntrinsicWidth(height));
final _TabBarParentData childParentData = child.parentData;
child = childParentData.nextSibling;
}
return isScrollable ? maxWidth : maxWidth * childCount;
}
@override
double computeMaxIntrinsicWidth(double height) {
double maxWidth = 0.0;
double totalWidth = 0.0;
RenderBox child = firstChild;
while (child != null) {
double childWidth = child.getMaxIntrinsicWidth(height);
maxWidth = math.max(maxWidth, childWidth);
totalWidth += childWidth;
final _TabBarParentData childParentData = child.parentData;
child = childParentData.nextSibling;
}
return isScrollable ? totalWidth : maxWidth * childCount;
}
double get _tabHeight => textAndIcons ? _kTextAndIconTabHeight : _kTabHeight;
double get _tabBarHeight => _tabHeight + _kTabIndicatorHeight;
@override
double computeMinIntrinsicHeight(double width) => _tabBarHeight;
@override
double computeMaxIntrinsicHeight(double width) => _tabBarHeight;
void layoutFixedWidthTabs() {
double tabWidth = size.width / childCount;
BoxConstraints tabConstraints =
new BoxConstraints.tightFor(width: tabWidth, height: _tabHeight);
double x = 0.0;
RenderBox child = firstChild;
while (child != null) {
child.layout(tabConstraints);
final _TabBarParentData childParentData = child.parentData;
childParentData.offset = new Offset(x, 0.0);
x += tabWidth;
child = childParentData.nextSibling;
}
}
double layoutScrollableTabs() {
BoxConstraints tabConstraints = new BoxConstraints(
minWidth: _kMinTabWidth,
maxWidth: _kMaxTabWidth,
minHeight: _tabHeight,
maxHeight: _tabHeight
);
double x = 0.0;
RenderBox child = firstChild;
while (child != null) {
child.layout(tabConstraints, parentUsesSize: true);
final _TabBarParentData childParentData = child.parentData;
childParentData.offset = new Offset(x, 0.0);
x += child.size.width;
child = childParentData.nextSibling;
}
return x;
}
Size layoutSize;
List<double> layoutWidths;
_TabLayoutChanged onLayoutChanged;
void reportLayoutChangedIfNeeded() {
assert(onLayoutChanged != null);
List<double> widths = new List<double>(childCount);
if (!isScrollable && childCount > 0) {
double tabWidth = size.width / childCount;
widths.fillRange(0, widths.length, tabWidth);
} else if (isScrollable) {
RenderBox child = firstChild;
int childIndex = 0;
while (child != null) {
widths[childIndex++] = child.size.width;
final _TabBarParentData childParentData = child.parentData;
child = childParentData.nextSibling;
}
assert(childIndex == widths.length);
}
if (size != layoutSize || widths != layoutWidths) {
layoutSize = size;
layoutWidths = widths;
onLayoutChanged(layoutSize, layoutWidths);
}
}
@override
void performLayout() {
assert(constraints is BoxConstraints);
if (childCount == 0)
return;
if (isScrollable) {
double tabBarWidth = layoutScrollableTabs();
size = constraints.constrain(new Size(tabBarWidth, _tabBarHeight));
} else {
size = constraints.constrain(new Size(constraints.maxWidth, _tabBarHeight));
layoutFixedWidthTabs();
}
if (onLayoutChanged != null)
reportLayoutChangedIfNeeded();
}
@override
bool hitTestChildren(HitTestResult result, { Point position }) {
return defaultHitTestChildren(result, position: position);
}
void _paintIndicator(Canvas canvas, RenderBox selectedTab, Offset offset) {
if (indicatorColor == null)
return;
if (indicatorRect != null) {
canvas.drawRect(indicatorRect.shift(offset), new Paint()..color = indicatorColor);
return;
}
final Size size = new Size(selectedTab.size.width, _kTabIndicatorHeight);
final _TabBarParentData selectedTabParentData = selectedTab.parentData;
final Point point = new Point(
selectedTabParentData.offset.dx,
_tabBarHeight - _kTabIndicatorHeight
);
canvas.drawRect((point + offset) & size, new Paint()..color = indicatorColor);
}
@override
void paint(PaintingContext context, Offset offset) {
int index = 0;
RenderBox child = firstChild;
while (child != null) {
final _TabBarParentData childParentData = child.parentData;
context.paintChild(child, childParentData.offset + offset);
if (index++ == selectedIndex)
_paintIndicator(context.canvas, child, offset);
child = childParentData.nextSibling;
}
}
}
class _TabBarWrapper extends MultiChildRenderObjectWidget {
_TabBarWrapper({
/// A material design [TabBar] tab. If both [icon] and [text] are
/// provided, the text is displayed below the icon.
///
/// See also:
///
/// * [TabBar], which displays a row of tabs.
/// * [TabBarView], which displays a widget for the currently selected tab.
/// * [TabController], which coordinates tab selection between a [TabBar] and a [TabBarView].
/// * <https://material.google.com/components/tabs.html>
class Tab extends StatelessWidget {
/// Creates a material design [TabBar] tab. At least one of [text] and [icon]
/// must be non-null.
Tab({
Key key,
List<Widget> children,
this.selectedIndex,
this.indicatorColor,
this.indicatorRect,
this.textAndIcons,
this.isScrollable: false,
this.onLayoutChanged
}) : super(key: key, children: children);
final int selectedIndex;
final Color indicatorColor;
final Rect indicatorRect;
final bool textAndIcons;
final bool isScrollable;
final _TabLayoutChanged onLayoutChanged;
@override
_RenderTabBar createRenderObject(BuildContext context) {
_RenderTabBar result = new _RenderTabBar(onLayoutChanged);
updateRenderObject(context, result);
return result;
}
@override
void updateRenderObject(BuildContext context, _RenderTabBar renderObject) {
renderObject
..selectedIndex = selectedIndex
..indicatorColor = indicatorColor
..indicatorRect = indicatorRect
..textAndIcons = textAndIcons
..isScrollable = isScrollable
..onLayoutChanged = onLayoutChanged;
this.text,
this.icon,
}) : super(key: key) {
assert(text != null || icon != null);
}
}
/// Signature for building icons for [TabLabel]s.
///
/// Used by [TabLabel.iconBuilder].
typedef Widget TabLabelIconBuilder(BuildContext context, Color color);
/// Each TabBar tab can display either a title [text], an icon, or both. An icon
/// can be specified by either the [icon] or [iconBuilder] parameters. In either
/// case the icon will occupy a 24x24 box above the title text. If iconBuilder
/// is specified its color parameter is the color that an ordinary icon would
/// have been drawn with. The color reflects that tab's selection state.
class TabLabel {
/// Creates a tab label description.
///
/// At least one of [text], [icon], or [iconBuilder] must be non-null.
const TabLabel({ this.text, this.icon, this.iconBuilder });
/// The text to display as the label of the tab.
/// The text to display as the tab's label.
final String text;
/// The icon to display as the label of the tab.
///
/// The size and color of the icon is configured automatically using an
/// [IconTheme] and therefore does not need to be explicitly given in the
/// icon widget.
///
/// See [Icon], [ImageIcon].
/// An icon to display as the tab's label.
final Widget icon;
/// Called if [icon] is null to build an icon as a label for this tab.
///
/// The color argument to this builder is the color that an ordinary icon
/// would have been drawn with. The color reflects that tab's selection state.
///
/// Return value must be non-null.
final TabLabelIconBuilder iconBuilder;
/// Whether this label has any text (specified using [text]).
bool get hasText => text != null;
/// Whether this label has an icon (specified either using [icon] or [iconBuilder]).
bool get hasIcon => icon != null || iconBuilder != null;
}
class _Tab extends StatelessWidget {
_Tab({
Key key,
this.onSelected,
this.label,
this.color
}) : super(key: key) {
assert(label.hasText || label.hasIcon);
}
final VoidCallback onSelected;
final TabLabel label;
final Color color;
Widget _buildLabelText() {
assert(label.text != null);
TextStyle style = new TextStyle(color: color);
return new Text(
label.text,
style: style,
softWrap: false,
overflow: TextOverflow.fade
);
}
Widget _buildLabelIcon(BuildContext context) {
assert(label.hasIcon);
if (label.icon != null) {
return new IconTheme.merge(
context: context,
data: new IconThemeData(
color: color,
size: 24.0
),
child: label.icon
);
} else {
return new SizedBox(
width: 24.0,
height: 24.0,
child: label.iconBuilder(context, color)
);
}
return new Text(text, softWrap: false, overflow: TextOverflow.fade);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context));
Widget labelContent;
if (!label.hasIcon) {
labelContent = _buildLabelText();
} else if (!label.hasText) {
labelContent = _buildLabelIcon(context);
double height;
Widget label;
if (icon == null) {
height = _kTabHeight;
label = _buildLabelText();
} else if (text == null) {
height = _kTabHeight;
label = icon;
} else {
labelContent = new Column(
height = _kTextAndIconTabHeight;
label = new Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
new Container(
child: _buildLabelIcon(context),
child: icon,
margin: const EdgeInsets.only(bottom: 10.0)
),
_buildLabelText()
......@@ -400,349 +84,260 @@ class _Tab extends StatelessWidget {
);
}
Container centeredLabel = new Container(
child: new Center(child: labelContent, widthFactor: 1.0, heightFactor: 1.0),
return new Container(
padding: _kTabLabelPadding,
height: height,
constraints: const BoxConstraints(minWidth: _kMinTabWidth),
padding: _kTabLabelPadding
);
return new InkWell(
onTap: onSelected,
child: centeredLabel
child: new Center(child: label),
);
}
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('$label');
if (text != null)
description.add('text: $text');
if (icon != null)
description.add('icon: $icon');
}
}
class _TabsScrollBehavior extends BoundedBehavior {
_TabsScrollBehavior();
class _TabStyle extends AnimatedWidget {
_TabStyle({
Key key,
Animation<double> animation,
this.selected,
this.labelColor,
this.child
}) : super(key: key, animation: animation);
@override
bool isScrollable = true;
final bool selected;
final Color labelColor;
final Widget child;
@override
Simulation createScrollSimulation(double position, double velocity) {
if (!isScrollable)
return null;
return new BoundedFrictionSimulation(
_kTabBarScrollDrag, position, velocity, minScrollOffset, maxScrollOffset
Widget build(BuildContext context) {
final ThemeData themeData = Theme.of(context);
final TextStyle textStyle = themeData.primaryTextTheme.body2;
final Color selectedColor = labelColor ?? themeData.primaryTextTheme.body2.color;
final Color unselectedColor = selectedColor.withAlpha(0xB2); // 70% alpha
final Color color = selected
? Color.lerp(unselectedColor, selectedColor, animation.value)
: Color.lerp(selectedColor, unselectedColor, animation.value);
return new DefaultTextStyle(
style: textStyle.copyWith(color: color),
child: new IconTheme.merge(
context: context,
data: new IconThemeData(
size: 24.0,
color: color,
),
child: child,
),
);
}
@override
double applyCurve(double scrollOffset, double scrollDelta) {
return (isScrollable) ? super.applyCurve(scrollOffset, scrollDelta) : 0.0;
}
}
/// An abstract interface through which [TabBarSelection] reports changes.
abstract class TabBarSelectionAnimationListener {
/// Called when the status of the [TabBarSelection] animation changes.
void handleStatusChange(AnimationStatus status);
/// Called on each animation frame when the [TabBarSelection] animation ticks.
void handleProgressChange();
/// Called when the [TabBarSelection] is deactivated.
///
/// Implementations typically drop their reference to the [TabBarSelection]
/// during this callback.
void handleSelectionDeactivate();
}
/// Coordinates the tab selection between a [TabBar] and a [TabBarView].
///
/// Place a [TabBarSelection] widget in the tree such that it is a common
/// ancestor of both the [TabBar] and the [TabBarView]. Both the [TabBar] and
/// the [TabBarView] can alter which tab is selected. They coodinate by
/// listening to the selection value stored in a common ancestor
/// [TabBarSelection] selection widget.
class TabBarSelection<T> extends StatefulWidget {
/// Creates a tab bar selection.
///
/// The values argument must be non-null, non-empty, and each value must be
/// unique. The value argument must either be null or contained in the values
/// argument. The child argument must be non-null.
TabBarSelection({
Key key,
this.value,
@required this.values,
this.onChanged,
@required this.child
}) : super(key: key) {
assert(values != null && values.isNotEmpty);
assert(new Set<T>.from(values).length == values.length);
assert(value == null ? true : values.where((T e) => e == value).length == 1);
assert(child != null);
class _TabLabelBarRenderer extends RenderFlex {
_TabLabelBarRenderer({
List<RenderBox> children,
Axis direction,
MainAxisSize mainAxisSize,
MainAxisAlignment mainAxisAlignment,
CrossAxisAlignment crossAxisAlignment,
TextBaseline textBaseline,
this.onPerformLayout,
}) : super(
children: children,
direction: direction,
mainAxisSize: mainAxisSize,
mainAxisAlignment: mainAxisAlignment,
crossAxisAlignment: crossAxisAlignment,
textBaseline: textBaseline,
) {
assert(onPerformLayout != null);
}
/// The current value of the selection.
final T value;
/// The list of possible values that the selection can obtain.
List<T> values;
/// Called when the value of the selection should change.
///
/// The tab bar selection passes the new value to the callback but does not
/// actually change state until the parent widget rebuilds the tab bar
/// selection with the new value.
///
/// If null, the tab bar selection cannot change value.
final ValueChanged<T> onChanged;
/// The widget below this widget in the tree.
final Widget child;
@override
TabBarSelectionState<T> createState() => new TabBarSelectionState<T>();
/// The state from the closest instance of this class that encloses the given context.
///
/// Typical usage is as follows:
///
/// ```dart
/// TabBarSelectionState<Foo> tabState = TabBarSelection.of/*<Foo>*/(context);
/// ```
static TabBarSelectionState<dynamic/*=T*/> of/*<T>*/(BuildContext context) {
return context.ancestorStateOfType(const TypeMatcher<TabBarSelectionState<dynamic/*=T*/>>());
}
ValueChanged<List<double>> onPerformLayout;
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('current tab: $value');
description.add('available tabs: $values');
void performLayout() {
super.performLayout();
RenderBox child = firstChild;
final List<double> xOffsets = <double>[];
while (child != null) {
final FlexParentData childParentData = child.parentData;
xOffsets.add(childParentData.offset.dx);
assert(child.parentData == childParentData);
child = childParentData.nextSibling;
}
xOffsets.add(size.width); // So xOffsets[lastTabIndex + 1] is valid.
onPerformLayout(xOffsets);
}
}
/// State for a [TabBarSelection] widget.
///
/// Subclasses of [TabBarSelection] typically use [State] objects that extend
/// this class.
class TabBarSelectionState<T> extends State<TabBarSelection<T>> with SingleTickerProviderStateMixin {
// Both the TabBar and TabBarView classes access _controller because they
// alternately drive selection progress between tabs.
AnimationController _controller;
/// An animation that updates as the selected tab changes.
Animation<double> get animation => _controller.view;
final Map<T, int> _valueToIndex = new Map<T, int>();
@override
void initState() {
super.initState();
_controller = new AnimationController(
duration: _kTabBarScroll,
value: 1.0,
vsync: this,
// This class and its renderer class only exist to report the widths of the tabs
// upon layout. The tab widths are only used at paint time (see _IndicatorPainter)
// or in response to input.
class _TabLabelBar extends Flex {
_TabLabelBar({
Key key,
MainAxisAlignment mainAxisAlignment,
CrossAxisAlignment crossAxisAlignment,
List<Widget> children: const <Widget>[],
this.onPerformLayout,
}) : super(
key: key,
children: children,
direction: Axis.horizontal,
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
);
final ValueChanged<List<double>> onPerformLayout;
@override
RenderFlex createRenderObject(BuildContext context) {
return new _TabLabelBarRenderer(
direction: direction,
mainAxisAlignment: mainAxisAlignment,
mainAxisSize: mainAxisSize,
crossAxisAlignment: crossAxisAlignment,
textBaseline: textBaseline,
onPerformLayout: onPerformLayout,
);
_value = config.value ?? PageStorage.of(context)?.readState(context) ?? values.first;
// If the selection's values have changed since the selected value was saved with
// PageStorage.writeState() then use the default.
if (!values.contains(_value))
_value = values.first;
_previousValue = _value;
_initValueToIndex();
}
@override
void didUpdateConfig(TabBarSelection<T> oldConfig) {
super.didUpdateConfig(oldConfig);
if (values != oldConfig.values)
_initValueToIndex();
}
void _initValueToIndex() {
_valueToIndex.clear();
int index = 0;
for (T value in values)
_valueToIndex[value] = index++;
}
void _writeValue() {
PageStorage.of(context)?.writeState(context, _value);
void updateRenderObject(BuildContext context, _TabLabelBarRenderer renderObject) {
super.updateRenderObject(context, renderObject);
renderObject.onPerformLayout = onPerformLayout;
}
}
/// The list of possible values that the selection can obtain.
List<T> get values => config.values;
double _indexChangeProgress(TabController controller) {
if (!controller.indexIsChanging)
return 1.0;
final double controllerValue = controller.animation.value;
final double previousIndex = controller.previousIndex.toDouble();
final double currentIndex = controller.index.toDouble();
if (controllerValue == previousIndex)
return 0.0;
else if (controllerValue == currentIndex)
return 1.0;
else
return (controllerValue - previousIndex).abs() / (currentIndex - previousIndex).abs();
}
/// The previously selected value.
///
/// When the tab selection changes, the tab selection animates from the
/// previously selected value to the new value.
T get previousValue => _previousValue;
T _previousValue;
/// Whether the tab selection is in the process of animating from one value to
/// another.
// TODO(abarth): Try computing this value from _controller.state so we don't
// need to keep a separate bool in sync.
bool get valueIsChanging => _valueIsChanging;
bool _valueIsChanging = false;
/// The index of a given value in [values].
///
/// Runs in constant time.
int indexOf(T tabValue) => _valueToIndex[tabValue];
class _IndicatorPainter extends CustomPainter {
_IndicatorPainter(this.controller) : super(repaint: controller.animation);
/// The index of the currently selected value.
int get index => _valueToIndex[value];
TabController controller;
List<double> tabOffsets;
Color color;
Animatable<Rect> indicatorTween;
Rect currentRect;
/// The index of the previoulsy selected value.
int get previousIndex => indexOf(_previousValue);
// tabOffsets[index] is the offset of the left edge of the tab at index, and
// tabOffsets[tabOffsets.length] is the right edge of the last tab.
int get maxTabIndex => tabOffsets.length - 2;
/// The currently selected value.
///
/// Writing to this field will cause the tab selection to animate from the
/// previous value to the new value.
T get value => _value;
T _value;
set value(T newValue) {
if (newValue == _value)
return;
_previousValue = _value;
_value = newValue;
_writeValue();
_valueIsChanging = true;
// If the selected value change was triggered by a drag gesture, the current
// value of _controller.value will reflect where the gesture ended. While
// the drag was underway the controller's value indicates where the indicator
// and TabBarView scrollPositions are vis the indices of the two tabs adjacent
// to the selected one. So 0.5 means the drag didn't move at all, 0.0 means the
// drag extended to the beginning of the tab on the left and 1.0 likewise for
// the tab on the right. That is unless the index of the selected value was 0
// or values.length - 1. In those cases the controller's value just moves between
// the selected tab and the adjacent one. So: convert the controller's value
// here to reflect the fact that we're now moving between (just) the previous
// and current selection index.
double value;
if (_controller.status == AnimationStatus.completed)
value = 0.0;
else if (_previousValue == values.first)
value = _controller.value;
else if (_previousValue == values.last)
value = 1.0 - _controller.value;
else if (previousIndex < index)
value = (_controller.value - 0.5) * 2.0;
else
value = 1.0 - _controller.value * 2.0;
_controller
..value = value
..forward().then((_) {
// TODO(abarth): Consider using a status listener and checking for
// AnimationStatus.completed.
if (_controller.value == 1.0) {
if (config.onChanged != null)
config.onChanged(_value);
_valueIsChanging = false;
}
});
Rect indicatorRect(Size tabBarSize, int tabIndex) {
assert(tabOffsets != null && tabIndex >= 0 && tabIndex <= maxTabIndex);
final double tabLeft = tabOffsets[tabIndex];
final double tabRight = tabOffsets[tabIndex + 1];
final double tabTop = tabBarSize.height - _kTabIndicatorHeight;
return new Rect.fromLTWH(tabLeft, tabTop, tabRight - tabLeft, _kTabIndicatorHeight);
}
final List<TabBarSelectionAnimationListener> _animationListeners = <TabBarSelectionAnimationListener>[];
/// Calls listener methods every time the value or status of the selection animation changes.
///
/// Listeners can be removed with [removeAnimationListener].
void addAnimationListener(TabBarSelectionAnimationListener listener) {
_animationListeners.add(listener);
_controller
..addStatusListener(listener.handleStatusChange)
..addListener(listener.handleProgressChange);
}
/// Stop calling listener methods every time the value or status of the animation changes.
///
/// Listeners can be added with [addAnimationListener].
void removeAnimationListener(TabBarSelectionAnimationListener listener) {
_animationListeners.remove(listener);
_controller
..removeStatusListener(listener.handleStatusChange)
..removeListener(listener.handleProgressChange);
@override
void paint(Canvas canvas, Size size) {
if (controller.indexIsChanging) {
final Rect targetRect = indicatorRect(size, controller.index);
currentRect = Rect.lerp(currentRect ?? targetRect, targetRect, _indexChangeProgress(controller));
} else {
final int currentIndex = controller.index;
final Rect left = currentIndex > 0 ? indicatorRect(size, currentIndex - 1) : null;
final Rect middle = indicatorRect(size, currentIndex);
final Rect right = currentIndex < maxTabIndex ? indicatorRect(size, currentIndex + 1) : null;
final double index = controller.index.toDouble();
final double value = controller.animation.value;
if (value == index - 1.0)
currentRect = left ?? middle;
else if (value == index + 1.0)
currentRect = right ?? middle;
else if (value == index)
currentRect = middle;
else if (value < index)
currentRect = left == null ? middle : Rect.lerp(middle, left, index - value);
else
currentRect = right == null ? middle : Rect.lerp(middle, right, value - index);
}
assert(currentRect != null);
canvas.drawRect(currentRect, new Paint()..color = color);
}
@override
void deactivate() {
_controller.stop();
for (TabBarSelectionAnimationListener listener in _animationListeners.toList()) {
listener.handleSelectionDeactivate();
removeAnimationListener(listener);
static bool tabOffsetsNotEqual(List<double> a, List<double> b) {
assert(a != null && b != null && a.length == b.length);
for(int i = 0; i < a.length; i++) {
if (a[i] != b[i])
return true;
}
assert(_animationListeners.isEmpty);
_writeValue();
super.deactivate();
return false;
}
@override
Widget build(BuildContext context) {
return config.child;
bool shouldRepaint(_IndicatorPainter old) {
return controller != old.controller ||
tabOffsets?.length != old.tabOffsets?.length ||
tabOffsetsNotEqual(tabOffsets, old.tabOffsets);
}
}
// Used when the user is dragging the TabBar or the TabBarView left or right.
// Dragging from the selected tab to the left varies t between 0.5 and 0.0.
// Dragging towards the tab on the right varies t between 0.5 and 1.0.
class _TabIndicatorTween extends Tween<Rect> {
_TabIndicatorTween({ Rect begin, this.middle, Rect end }) : super(begin: begin, end: end);
class _ChangeAnimation extends Animation<double> with AnimationWithParentMixin<double> {
_ChangeAnimation(this.controller);
final Rect middle;
final TabController controller;
@override
Rect lerp(double t) {
return t <= 0.5
? Rect.lerp(begin, middle, t * 2.0)
: Rect.lerp(middle, end, (t - 0.5) * 2.0);
}
Animation<double> get parent => controller.animation;
@override
double get value => _indexChangeProgress(controller);
}
/// A widget that displays a horizontal row of tabs, one per label.
///
/// Requires one of its ancestors to be a [TabBarSelection] widget to enable
/// saving and monitoring the selected tab.
///
/// Requires one of its ancestors to be a [Material] widget.
/// A material design widget that displays a horizontal row of tabs. Typically
/// created as part of an [AppBar] and in conjuction with a [TabBarView].
///
/// See also:
/// If a [TabController] is not provided, then there must be a [DefaultTabController]
/// ancestor.
///
/// * [TabBarSelection]
/// * [TabBarView]
/// * [AppBar.tabBar]
/// * <https://material.google.com/components/tabs.html>
class TabBar<T> extends Scrollable implements AppBarBottomWidget {
/// Creates a widget that displays a horizontal row of tabs, one per label.
///
/// The [labels] argument must not be null.
/// Requires one of its ancestors to be a [Material] widget
class TabBar extends StatefulWidget implements AppBarBottomWidget {
TabBar({
Key key,
@required this.labels,
@required this.tabs,
this.controller,
this.isScrollable: false,
this.indicatorColor,
this.labelColor
}) : super(key: key, scrollDirection: Axis.horizontal) {
assert(labels != null);
this.labelColor,
}) : super(key: key) {
assert(tabs != null && tabs.length > 1);
assert(isScrollable != null);
}
/// The labels to display in the tabs.
/// Typically a list of [Tab] widgets.
final List<Widget> tabs;
/// This widget's selection and animation state.
///
/// The [TabBarSelection.values] are used as keys for this map to determine
/// which tab label is selected.
final Map<T, TabLabel> labels;
/// If [TabController] is not provided, then the value of [DefaultTabController.of]
/// will be used.
final TabController controller;
/// Whether this tab bar can be scrolled horizontally.
///
......@@ -760,543 +355,498 @@ class TabBar<T> extends Scrollable implements AppBarBottomWidget {
/// the color of the theme's body2 text color is used.
final Color labelColor;
/// The height of the tab labels and indicator.
@override
double get bottomHeight {
for (TabLabel label in labels.values) {
if (label.hasText && label.hasIcon)
return _kTextAndIconTabHeight + _kTabIndicatorHeight;
for (Widget widget in tabs) {
if (widget is Tab) {
final Tab tab = widget;
if (tab.text != null && tab.icon != null)
return _kTextAndIconTabHeight + _kTabIndicatorHeight;
}
}
return _kTabHeight + _kTabIndicatorHeight;
}
@override
_TabBarState<T> createState() => new _TabBarState<T>();
_TabBarState createState() => new _TabBarState();
}
class _TabBarState<T> extends ScrollableState<TabBar<T>> implements TabBarSelectionAnimationListener {
TabBarSelectionState<T> _selection;
bool _valueIsChanging = false;
int _lastSelectedIndex = -1;
class _TabBarState extends State<TabBar> {
final GlobalKey<ScrollableState> viewportKey = new GlobalKey<ScrollableState>();
void _initSelection(TabBarSelectionState<T> newSelection) {
if (_selection == newSelection)
TabController _controller;
_ChangeAnimation _changeAnimation;
_IndicatorPainter _indicatorPainter;
int _currentIndex;
void _updateTabController() {
TabController newController = config.controller ?? DefaultTabController.of(context);
if (newController == _controller)
return;
_selection?.removeAnimationListener(this);
_selection = newSelection;
_selection?.addAnimationListener(this);
if (_selection != null)
_lastSelectedIndex = _selection.index;
}
@override
void didUpdateConfig(TabBar<T> oldConfig) {
super.didUpdateConfig(oldConfig);
if (config.isScrollable != oldConfig.isScrollable) {
scrollBehavior.isScrollable = config.isScrollable;
if (!config.isScrollable)
scrollTo(0.0);
if (_controller != null)
_controller.animation.removeListener(_handleTick);
_controller = newController;
if (_controller != null) {
_controller.animation.addListener(_handleTick);
_changeAnimation = new _ChangeAnimation(_controller);
_currentIndex = _controller.index;
final List<double> offsets = _indicatorPainter?.tabOffsets;
_indicatorPainter = new _IndicatorPainter(_controller)..tabOffsets = offsets;
}
}
@override
void dispose() {
_selection?.removeAnimationListener(this);
super.dispose();
void dependenciesChanged() {
super.dependenciesChanged();
_updateTabController();
}
@override
void handleSelectionDeactivate() {
_selection = null;
}
// Initialize _indicatorTween for interactive dragging between the tab on the left
// and the tab on the right. In this case _selection.animation.value is 0.5 when
// the indicator is below the selected tab, 0.0 when it's under the left tab, and 1.0
// when it's under the tab on the right.
void _initIndicatorTweenForDrag() {
assert(!_valueIsChanging);
int index = _selection.index;
int beginIndex = math.max(0, index - 1);
int endIndex = math.min(config.labels.length - 1, index + 1);
if (beginIndex == index || endIndex == index) {
_indicatorTween = new RectTween(
begin: _tabIndicatorRect(beginIndex),
end: _tabIndicatorRect(endIndex)
);
} else {
_indicatorTween = new _TabIndicatorTween(
begin: _tabIndicatorRect(beginIndex),
middle: _tabIndicatorRect(index),
end: _tabIndicatorRect(endIndex)
);
}
}
// Initialize _indicatorTween for animating the selected tab indicator from the
// previously selected tab to the newly selected one. In this case
// _selection.animation.value is 0.0 when the indicator is below the previously
// selected tab, and 1.0 when it's under the newly selected one.
void _initIndicatorTweenForAnimation() {
assert(_valueIsChanging);
_indicatorTween = new RectTween(
begin: _indicatorRect ?? _tabIndicatorRect(_selection.previousIndex),
end: _tabIndicatorRect(_selection.index)
);
void didUpdateConfig(TabBar oldConfig) {
super.didUpdateConfig(oldConfig);
if (config.controller != oldConfig.controller)
_updateTabController();
}
@override
void handleStatusChange(AnimationStatus status) {
if (config.labels.isEmpty)
return;
if (_valueIsChanging && status == AnimationStatus.completed) {
_valueIsChanging = false;
setState(() {
_initIndicatorTweenForDrag();
_indicatorRect = _tabIndicatorRect(_selection.index);
});
}
void dispose() {
if (_controller != null)
_controller.animation.removeListener(_handleTick);
// We don't own the _controller Animation, so it's not disposed here.
super.dispose();
}
@override
void handleProgressChange() {
if (config.labels.isEmpty || _selection == null)
return;
if (_lastSelectedIndex != _selection.index) {
_valueIsChanging = true;
if (config.isScrollable)
scrollTo(_centeredTabScrollOffset(_selection.index), duration: _kTabBarScroll);
_initIndicatorTweenForAnimation();
_lastSelectedIndex = _selection.index;
} else if (_indicatorTween == null) {
_initIndicatorTweenForDrag();
}
// tabOffsets[index] is the offset of the left edge of the tab at index, and
// tabOffsets[tabOffsets.length] is the right edge of the last tab.
int get maxTabIndex => _indicatorPainter.tabOffsets.length - 2;
Rect oldRect = _indicatorRect;
double t = _selection.animation.value;
double _tabCenteredScrollOffset(ScrollableState viewport, int tabIndex) {
final List<double> tabOffsets = _indicatorPainter.tabOffsets;
assert(tabOffsets != null && tabIndex >= 0 && tabIndex <= maxTabIndex);
// When _valueIsChanging is false, we're animating based on drag gesture and
// want linear selected tab indicator motion. When _valueIsChanging is true,
// a ticker is driving the selection change and we want to curve the animation.
// In this case the leading and trailing edges of the move at different rates.
// The easiest way to do this is to lerp 2 rects, and piece them together into 1.
if (!_valueIsChanging) {
_indicatorRect = _indicatorTween.lerp(t);
} else {
Rect leftRect, rightRect;
if (_selection.index > _selection.previousIndex) {
// Moving to the right - right edge is leading.
rightRect = _indicatorTween.lerp(_kTabIndicatorLeadingCurve.transform(t));
leftRect = _indicatorTween.lerp(_kTabIndicatorTrailingCurve.transform(t));
} else {
// Moving to the left - left edge is leading.
leftRect = _indicatorTween.lerp(_kTabIndicatorLeadingCurve.transform(t));
rightRect = _indicatorTween.lerp(_kTabIndicatorTrailingCurve.transform(t));
}
_indicatorRect = new Rect.fromLTRB(
leftRect.left, leftRect.top, rightRect.right, rightRect.bottom
);
}
if (oldRect != _indicatorRect)
setState(() { /* The indicator rect has changed. */ });
}
Size _viewportSize = Size.zero;
Size _tabBarSize;
List<double> _tabWidths;
Rect _indicatorRect;
Tween<Rect> _indicatorTween;
Rect _tabRect(int tabIndex) {
assert(_tabBarSize != null);
assert(_tabWidths != null);
assert(tabIndex >= 0 && tabIndex < _tabWidths.length);
double tabLeft = 0.0;
if (tabIndex > 0)
tabLeft = _tabWidths.take(tabIndex).reduce((double sum, double width) => sum + width);
final double tabTop = 0.0;
final double tabBottom = _tabBarSize.height - _kTabIndicatorHeight;
final double tabRight = tabLeft + _tabWidths[tabIndex];
return new Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom);
final ExtentScrollBehavior scrollBehavior = viewport.scrollBehavior;
final double viewportWidth = scrollBehavior.containerExtent;
final double tabCenter = (tabOffsets[tabIndex] + tabOffsets[tabIndex + 1]) / 2.0;
return (tabCenter - viewportWidth / 2.0)
.clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset);
}
Rect _tabIndicatorRect(int tabIndex) {
Rect r = _tabRect(tabIndex);
return new Rect.fromLTRB(r.left, r.bottom, r.right, r.bottom + _kTabIndicatorHeight);
}
void _scrollToCurrentIndex() {
final ScrollableState viewport = viewportKey.currentState;
final double offset = _tabCenteredScrollOffset(viewport, _currentIndex);
viewport.scrollTo(offset, duration: kTabScrollDuration);
}
void _scrollToControllerValue() {
final ScrollableState viewport = viewportKey.currentState;
final double left = _currentIndex > 0 ? _tabCenteredScrollOffset(viewport, _currentIndex - 1) : null;
final double middle = _tabCenteredScrollOffset(viewport, _currentIndex);
final double right = _currentIndex < maxTabIndex ? _tabCenteredScrollOffset(viewport, _currentIndex + 1) : null;
final double index = _controller.index.toDouble();
final double value = _controller.animation.value;
double offset;
if (value == index - 1.0)
offset = left ?? middle;
else if (value == index + 1.0)
offset = right ?? middle;
else if (value == index)
offset = middle;
else if (value < index)
offset = left == null ? middle : lerpDouble(middle, left, index - value);
else
offset = right == null ? middle : lerpDouble(middle, right, value - index);
@override
ExtentScrollBehavior createScrollBehavior() {
return new _TabsScrollBehavior()
..isScrollable = config.isScrollable;
viewport.scrollTo(offset);
}
@override
_TabsScrollBehavior get scrollBehavior => super.scrollBehavior;
void _handleTick() {
assert(mounted);
double _centeredTabScrollOffset(int tabIndex) {
double viewportWidth = scrollBehavior.containerExtent;
Rect tabRect = _tabRect(tabIndex);
return (tabRect.left + tabRect.width / 2.0 - viewportWidth / 2.0)
.clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset);
}
void _handleTabSelected(int tabIndex) {
if (_selection != null && tabIndex != _selection.index)
if (_controller.indexIsChanging) {
setState(() {
_selection.value = _selection.values[tabIndex];
// Rebuild so that the tab label colors reflect the selected tab index.
// The first build for a new _controller.index value will also trigger
// a scroll to center the selected tab.
});
} else if (config.isScrollable) {
_scrollToControllerValue();
}
}
Widget _toTab(TabLabel label, int tabIndex, Color color, Color selectedColor) {
Color labelColor = color;
if (_selection != null) {
final bool isSelectedTab = tabIndex == _selection.index;
final bool isPreviouslySelectedTab = tabIndex == _selection.previousIndex;
labelColor = isSelectedTab ? selectedColor : color;
if (_selection.valueIsChanging) {
if (isSelectedTab)
labelColor = Color.lerp(color, selectedColor, _selection.animation.value);
else if (isPreviouslySelectedTab)
labelColor = Color.lerp(selectedColor, color, _selection.animation.value);
}
}
return new _Tab(
onSelected: () { _handleTabSelected(tabIndex); },
label: label,
color: labelColor
);
void _saveTabOffsets(List<double> tabOffsets) {
_indicatorPainter?.tabOffsets = tabOffsets;
}
void _updateScrollBehavior() {
didUpdateScrollBehavior(scrollBehavior.updateExtents(
containerExtent: config.scrollDirection == Axis.vertical ? _viewportSize.height : _viewportSize.width,
contentExtent: _tabWidths.reduce((double sum, double width) => sum + width),
scrollOffset: scrollOffset
));
void _handleTap(int index) {
assert(index >= 0 && index < config.tabs.length);
_controller.animateTo(index);
}
void _layoutChanged(Size tabBarSize, List<double> tabWidths) {
// This is bad. We should use a LayoutBuilder or CustomMultiChildLayout or some such.
// As designed today, tabs are always lagging one frame behind, taking two frames
// to handle a layout change.
_tabBarSize = tabBarSize;
_tabWidths = tabWidths;
_indicatorRect = _selection != null ? _tabIndicatorRect(_selection.index) : Rect.zero;
_updateScrollBehavior();
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
if (mounted) {
setState(() {
// the changes were made at layout time
// TODO(ianh): remove this setState: https://github.com/flutter/flutter/issues/5749
});
@override
Widget build(BuildContext context) {
final List<Widget> wrappedTabs = new List<Widget>.from(config.tabs, growable: false);
// If the controller was provided by DefaultTabController and we're part
// of a Hero (typically the AppBar), then we will not be able to find the
// controller during a Hero transition. See https://github.com/flutter/flutter/issues/213.
if (_controller != null) {
_indicatorPainter.color = config.indicatorColor ?? Theme.of(context).indicatorColor;
if (_indicatorPainter.color == Material.of(context).color) {
// ThemeData tries to avoid this by having indicatorColor avoid being the
// primaryColor. However, it's possible that the tab bar is on a
// Material that isn't the primaryColor. In that case, if the indicator
// color ends up clashing, then this overrides it. When that happens,
// automatic transitions of the theme will likely look ugly as the
// indicator color suddenly snaps to white at one end, but it's not clear
// how to avoid that any further.
_indicatorPainter.color = Colors.white;
}
});
}
Offset _handlePaintOffsetUpdateNeeded(ViewportDimensions dimensions) {
// We make various state changes here but don't have to do so in a
// setState() callback because we are called during layout and all
// we're updating is the new offset, which we are providing to the
// render object via our return value.
_viewportSize = dimensions.containerSize;
_updateScrollBehavior();
if (config.isScrollable && _selection != null)
scrollTo(_centeredTabScrollOffset(_selection.index), duration: _kTabBarScroll);
return scrollOffsetToPixelDelta(scrollOffset);
}
if (_controller.index != _currentIndex) {
_currentIndex = _controller.index;
if (config.isScrollable)
_scrollToCurrentIndex();
}
@override
Widget buildContent(BuildContext context) {
TabBarSelectionState<T> newSelection = TabBarSelection.of(context);
_initSelection(newSelection);
assert(config.labels.isNotEmpty);
assert(Material.of(context) != null);
ThemeData themeData = Theme.of(context);
Color backgroundColor = Material.of(context).color;
Color indicatorColor = config.indicatorColor ?? themeData.indicatorColor;
if (indicatorColor == backgroundColor) {
// ThemeData tries to avoid this by having indicatorColor avoid being the
// primaryColor. However, it's possible that the tab bar is on a
// Material that isn't the primaryColor. In that case, if the indicator
// color ends up clashing, then this overrides it. When that happens,
// automatic transitions of the theme will likely look ugly as the
// indicator color suddenly snaps to white at one end, but it's not clear
// how to avoid that any further.
indicatorColor = Colors.white;
}
final int previousIndex = _controller.previousIndex;
final TextStyle textStyle = themeData.primaryTextTheme.body2;
final Color selectedLabelColor = config.labelColor ?? themeData.primaryTextTheme.body2.color;
final Color labelColor = selectedLabelColor.withAlpha(0xB2); // 70% alpha
List<Widget> tabs = <Widget>[];
bool textAndIcons = false;
int tabIndex = 0;
for (TabLabel label in config.labels.values) {
tabs.add(_toTab(label, tabIndex++, labelColor, selectedLabelColor));
if (label.hasText && label.hasIcon)
textAndIcons = true;
if (_controller.indexIsChanging) {
assert(_currentIndex != previousIndex);
wrappedTabs[_currentIndex] = new _TabStyle(
animation: _changeAnimation,
selected: true,
labelColor: config.labelColor,
child: wrappedTabs[_currentIndex],
);
wrappedTabs[previousIndex] = new _TabStyle(
animation: _changeAnimation,
selected: false,
labelColor: config.labelColor,
child: wrappedTabs[previousIndex],
);
} else {
wrappedTabs[_currentIndex] = new _TabStyle(
animation: kAlwaysCompleteAnimation,
selected: true,
labelColor: config.labelColor,
child: wrappedTabs[_currentIndex],
);
}
}
Widget contents = new DefaultTextStyle(
style: textStyle,
child: new _TabBarWrapper(
children: tabs,
selectedIndex: _selection?.index,
indicatorColor: indicatorColor,
indicatorRect: _indicatorRect,
textAndIcons: textAndIcons,
isScrollable: config.isScrollable,
onLayoutChanged: _layoutChanged
)
// Add the tap handler to each tab. If the tab bar is scrollable
// then give all of the tabs equal flexibility so that their widths
// reflect the intrinsic width of their labels.
for (int index = 0; index < config.tabs.length; index++) {
wrappedTabs[index] = new InkWell(
onTap: () { _handleTap(index); },
child: wrappedTabs[index],
);
if (!config.isScrollable)
wrappedTabs[index] = new Flexible(child: wrappedTabs[index]);
}
Widget tabBar = new CustomPaint(
painter: _indicatorPainter,
child: new Padding(
padding: const EdgeInsets.only(bottom: _kTabIndicatorHeight),
child: new _TabStyle(
animation: kAlwaysCompleteAnimation,
selected: false,
labelColor: config.labelColor,
child: new _TabLabelBar(
onPerformLayout: _saveTabOffsets,
children: wrappedTabs,
),
),
),
);
if (config.isScrollable) {
return new Viewport(
mainAxis: Axis.horizontal,
paintOffset: scrollOffsetToPixelDelta(scrollOffset),
onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded,
child: contents
tabBar = new ScrollableViewport(
scrollableKey: viewportKey,
scrollDirection: Axis.horizontal,
child: tabBar
);
}
return contents;
return tabBar;
}
}
/// A widget that displays the contents of a tab.
///
/// Requires one of its ancestors to be a [TabBarSelection] widget to enable
/// saving and monitoring the selected tab.
///
/// See also:
///
/// * [TabBarSelection]
/// * [TabBar]
/// * <https://material.google.com/components/tabs.html>
class TabBarView<T> extends PageableList {
/// Creates a widget that displays the contents of a tab.
///
/// The [children] argument must not be null and must not be empty.
TabBarView({
class _PageableTabBarView extends PageableList {
_PageableTabBarView({
Key key,
@required List<Widget> children
List<Widget> children,
double initialScrollOffset: 0.0,
}) : super(
key: key,
scrollDirection: Axis.horizontal,
children: children
) {
assert(children != null);
assert(children.length > 1);
}
children: children,
initialScrollOffset: initialScrollOffset,
);
@override
_TabBarViewState<T> createState() => new _TabBarViewState<T>();
_PageableTabBarViewState createState() => new _PageableTabBarViewState();
}
class _TabBarViewState<T> extends PageableListState<TabBarView<T>> implements TabBarSelectionAnimationListener {
TabBarSelectionState<T> _selection;
List<Widget> _items;
int get _tabCount => config.children.length;
class _PageableTabBarViewState extends PageableListState<_PageableTabBarView> {
BoundedBehavior _boundedBehavior;
@override
ExtentScrollBehavior get scrollBehavior {
_boundedBehavior ??= new BoundedBehavior(platform: platform);
_boundedBehavior ??= new BoundedBehavior(
platform: platform,
containerExtent: 1.0,
contentExtent: config.children.length.toDouble(),
);
return _boundedBehavior;
}
@override
TargetPlatform get platform => Theme.of(context).platform;
void _initSelection(TabBarSelectionState<T> newSelection) {
if (_selection == newSelection)
return;
_selection?.removeAnimationListener(this);
_selection = newSelection;
_selection?.addAnimationListener(this);
if (_selection != null)
_updateItemsAndScrollBehavior();
@override
Future<Null> fling(double scrollVelocity) {
final double newScrollOffset = snapScrollOffset(scrollOffset + scrollVelocity.sign)
.clamp(snapScrollOffset(scrollOffset - 0.5), snapScrollOffset(scrollOffset + 0.5))
.clamp(0.0, (config.children.length - 1).toDouble());
return scrollTo(newScrollOffset, duration: config.duration, curve: config.curve);
}
@override
void didUpdateConfig(TabBarView<T> oldConfig) {
super.didUpdateConfig(oldConfig);
if (_selection != null && config.children != oldConfig.children)
_updateItemsForSelectedIndex(_selection.index);
Widget buildContent(BuildContext context) {
return new PageViewport(
mainAxis: config.scrollDirection,
startOffset: scrollOffset,
children: config.children,
);
}
}
@override
void dispose() {
_selection?.removeAnimationListener(this);
super.dispose();
/// A pageable list that displays the widget which corresponds to the currently
/// selected tab. Typically used in conjuction with a [TabBar].
///
/// If a [TabController] is not provided, then there must be a [DefaultTabController]
/// ancestor.
class TabBarView extends StatefulWidget {
/// Creates a pageable list with one child per tab.
///
/// The length of [children] must be the same as the [controller]'s length.
TabBarView({
Key key,
@required this.children,
this.controller,
}) : super(key: key) {
assert(children != null && children.length > 1);
}
/// This widget's selection and animation state.
///
/// If [TabController] is not provided, then the value of [DefaultTabController.of]
/// will be used.
final TabController controller;
/// One widget per tab.
final List<Widget> children;
@override
void handleSelectionDeactivate() {
_selection = null;
}
_TabBarViewState createState() => new _TabBarViewState();
}
void _updateItemsFromChildren(int first, int second, [int third]) {
List<Widget> widgets = config.children;
_items = <Widget>[
new KeyedSubtree.wrap(widgets[first], first),
new KeyedSubtree.wrap(widgets[second], second),
];
if (third != null)
_items.add(new KeyedSubtree.wrap(widgets[third], third));
}
class _TabBarViewState extends State<TabBarView> {
final GlobalKey<ScrollableState> viewportKey = new GlobalKey<ScrollableState>();
void _updateItemsForSelectedIndex(int selectedIndex) {
if (selectedIndex == 0) {
_updateItemsFromChildren(0, 1);
} else if (selectedIndex == _tabCount - 1) {
_updateItemsFromChildren(selectedIndex - 1, selectedIndex);
} else {
_updateItemsFromChildren(selectedIndex - 1, selectedIndex, selectedIndex + 1);
}
TabController _controller;
List<Widget> _children;
double _offsetAnchor;
double _offsetBias = 0.0;
int _currentIndex;
int _warpUnderwayCount = 0;
void _updateTabController() {
TabController newController = config.controller ?? DefaultTabController.of(context);
if (newController == _controller)
return;
if (_controller != null)
_controller.animation.removeListener(_handleTick);
_controller = newController;
if (_controller != null)
_controller.animation.addListener(_handleTick);
}
void _updateScrollBehaviorForSelectedIndex(int selectedIndex) {
if (selectedIndex == 0) {
didUpdateScrollBehavior(scrollBehavior.updateExtents(contentExtent: 2.0, containerExtent: 1.0, scrollOffset: 0.0));
} else if (selectedIndex == _tabCount - 1) {
didUpdateScrollBehavior(scrollBehavior.updateExtents(contentExtent: 2.0, containerExtent: 1.0, scrollOffset: 1.0));
} else {
didUpdateScrollBehavior(scrollBehavior.updateExtents(contentExtent: 3.0, containerExtent: 1.0, scrollOffset: 1.0));
}
@override
void initState() {
super.initState();
_children = config.children;
}
void _updateItemsAndScrollBehavior() {
assert(_selection != null);
final int selectedIndex = _selection.index;
assert(selectedIndex != null);
_updateItemsForSelectedIndex(selectedIndex);
_updateScrollBehaviorForSelectedIndex(selectedIndex);
@override
void dependenciesChanged() {
super.dependenciesChanged();
_updateTabController();
_currentIndex = _controller?.index;
}
@override
void handleStatusChange(AnimationStatus status) {
void didUpdateConfig(TabBarView oldConfig) {
super.didUpdateConfig(oldConfig);
if (config.controller != oldConfig.controller)
_updateTabController();
if (config.children != oldConfig.children && _warpUnderwayCount == 0)
_children = config.children;
}
@override
void handleProgressChange() {
if (_selection == null || !_selection.valueIsChanging)
return;
// The TabBar is driving the TabBarSelection animation.
void dispose() {
if (_controller != null)
_controller.animation.removeListener(_handleTick);
// We don't own the _controller Animation, so it's not disposed here.
super.dispose();
}
final Animation<double> animation = _selection.animation;
void _handleTick() {
if (!_controller.indexIsChanging)
return; // This widget is driving the controller's animation.
if (animation.status == AnimationStatus.completed) {
_updateItemsAndScrollBehavior();
return;
if (_controller.index != _currentIndex) {
_currentIndex = _controller.index;
_warpToCurrentIndex();
}
}
if (animation.status != AnimationStatus.forward)
return;
Future<Null> _warpToCurrentIndex() async {
if (!mounted)
return new Future<Null>.value();
final int selectedIndex = _selection.index;
final int previousSelectedIndex = _selection.previousIndex;
final ScrollableState viewport = viewportKey.currentState;
if (viewport.scrollOffset == _currentIndex.toDouble())
return new Future<Null>.value();
if (selectedIndex < previousSelectedIndex) {
_updateItemsFromChildren(selectedIndex, previousSelectedIndex);
scrollTo(new CurveTween(curve: Curves.fastOutSlowIn.flipped).evaluate(new ReverseAnimation(animation)));
} else {
_updateItemsFromChildren(previousSelectedIndex, selectedIndex);
scrollTo(new CurveTween(curve: Curves.fastOutSlowIn).evaluate(animation));
}
}
final int previousIndex = _controller.previousIndex;
if ((_currentIndex - previousIndex).abs() == 1)
return viewport.scrollTo(_currentIndex.toDouble(), duration: kTabScrollDuration);
assert((_currentIndex - previousIndex).abs() > 1);
double initialScroll;
setState(() {
_warpUnderwayCount += 1;
_children = new List<Widget>.from(config.children, growable: false);
if (_currentIndex > previousIndex) {
_children[_currentIndex - 1] = _children[previousIndex];
initialScroll = (_currentIndex - 1).toDouble();
} else {
_children[_currentIndex + 1] = _children[previousIndex];
initialScroll = (_currentIndex + 1).toDouble();
}
});
@override
void dispatchOnScroll() {
if (_selection == null || _selection.valueIsChanging)
return;
// This class is driving the TabBarSelection's animation.
await viewport.scrollTo(initialScroll);
if (!mounted)
return new Future<Null>.value();
final AnimationController controller = _selection._controller;
await viewport.scrollTo(_currentIndex.toDouble(), duration: kTabScrollDuration);
if (!mounted)
return new Future<Null>.value();
if (_selection.index == 0 || _selection.index == _tabCount - 1)
controller.value = scrollOffset;
else
controller.value = scrollOffset / 2.0;
setState(() {
_warpUnderwayCount -= 1;
_children = config.children;
});
}
@override
Future<Null> fling(double scrollVelocity) {
if (_selection == null || _selection.valueIsChanging)
return new Future<Null>.value();
// Called when the _PageableTabBarView scrolls
bool _handleScrollNotification(ScrollNotification notification) {
if (_warpUnderwayCount > 0)
return false;
if (scrollVelocity.abs() > _kMinFlingVelocity) {
final int selectionDelta = scrollVelocity.sign.truncate();
final int targetIndex = (_selection.index + selectionDelta).clamp(0, _tabCount - 1);
if (_selection.index != targetIndex) {
_selection.value = _selection.values[targetIndex];
return new Future<Null>.value();
}
}
final ScrollableState scrollable = notification.scrollable;
if (scrollable.config.key != viewportKey)
return false;
final int selectionIndex = _selection.index;
final int settleIndex = snapScrollOffset(scrollOffset).toInt();
if (selectionIndex > 0 && settleIndex != 1) {
final int targetIndex = (selectionIndex + (settleIndex == 2 ? 1 : -1)).clamp(0, _tabCount - 1);
_selection.value = _selection.values[targetIndex];
return new Future<Null>.value();
} else if (selectionIndex == 0 && settleIndex == 1) {
_selection.value = _selection.values[1];
return new Future<Null>.value();
switch(notification.kind) {
case ScrollNotificationKind.started:
_offsetAnchor = null;
break;
case ScrollNotificationKind.updated:
if (!_controller.indexIsChanging) {
_offsetAnchor ??= scrollable.scrollOffset;
_controller.offset = (_offsetBias + scrollable.scrollOffset - _offsetAnchor).clamp(-1.0, 1.0);
}
break;
// Either the the animation that follows a fling has completed and we've landed
// on a new tab view, or a new pointer gesture has interrupted the fling
// animation before it has completed.
case ScrollNotificationKind.ended:
final double integralScrollOffset = scrollable.scrollOffset.floorToDouble();
if (integralScrollOffset == scrollable.scrollOffset) {
_offsetBias = 0.0;
// The animation duration is short since the tab indicator and this
// pageable list have already moved.
_controller.animateTo(
integralScrollOffset.floor(),
duration: const Duration(milliseconds: 30)
);
} else {
// The fling scroll animation was interrupted.
_offsetBias = _controller.offset;
}
break;
}
return settleScrollOffset();
return false;
}
@override
Widget buildContent(BuildContext context) {
TabBarSelectionState<T> newSelection = TabBarSelection.of(context);
_initSelection(newSelection);
return new PageViewport(
itemsWrap: config.itemsWrap,
mainAxis: config.scrollDirection,
startOffset: scrollOffset,
children: _items
Widget build(BuildContext context) {
return new NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: new _PageableTabBarView(
key: viewportKey,
children: _children,
initialScrollOffset: (_controller?.index ?? 0).toDouble(),
),
);
}
}
/// A widget that displays a visual indicator of which tab is selected.
///
/// Requires one of its ancestors to be a [TabBarSelection] widget to enable
/// saving and monitoring the selected tab.
///
/// See also:
/// Displays a row of small circular indicators, one per tab. The selected
/// tab's indicator is highlighted. Often used in conjuction with a [TabBarView].
///
/// * [TabBarSelection]
/// * [TabBarView]
class TabPageSelector<T> extends StatelessWidget {
/// Creates a widget that displays a visual indicator of which tab is selected.
///
/// Requires one of its ancestors to be a [TabBarSelection] widget to enable
/// saving and monitoring the selected tab.
const TabPageSelector({ Key key }) : super(key: key);
/// If a [TabController] is not provided, then there must be a [DefaultTabController]
/// ancestor.
class TabPageSelector extends StatelessWidget {
/// Creates a compact widget that indicates which tab has been selected.
TabPageSelector({ Key key, this.controller }) : super(key: key);
Widget _buildTabIndicator(TabBarSelectionState<T> selection, T tab, Animation<double> animation, ColorTween selectedColor, ColorTween previousColor) {
/// This widget's selection and animation state.
///
/// If [TabController] is not provided, then the value of [DefaultTabController.of]
/// will be used.
final TabController controller;
Widget _buildTabIndicator(
int tabIndex,
TabController tabController,
ColorTween selectedColor,
ColorTween previousColor,
) {
Color background;
if (selection.valueIsChanging) {
if (tabController.indexIsChanging) {
// The selection's animation is animating from previousValue to value.
if (selection.value == tab)
background = selectedColor.evaluate(animation);
else if (selection.previousValue == tab)
background = previousColor.evaluate(animation);
if (tabController.index == tabIndex)
background = selectedColor.lerp(_indexChangeProgress(tabController));
else if (tabController.previousIndex == tabIndex)
background = previousColor.lerp(_indexChangeProgress(tabController));
else
background = selectedColor.begin;
} else {
background = selection.value == tab ? selectedColor.end : selectedColor.begin;
background = tabController.index == tabIndex ? selectedColor.end : selectedColor.begin;
}
return new Container(
width: 12.0,
......@@ -1312,20 +862,25 @@ class TabPageSelector<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
final TabBarSelectionState<T> selection = TabBarSelection.of(context);
final Color color = Theme.of(context).accentColor;
final ColorTween selectedColor = new ColorTween(begin: Colors.transparent, end: color);
final ColorTween previousColor = new ColorTween(begin: color, end: Colors.transparent);
Animation<double> animation = new CurvedAnimation(parent: selection.animation, curve: Curves.fastOutSlowIn);
final TabController tabController = controller ?? DefaultTabController.of(context);
final Animation<double> animation = new CurvedAnimation(
parent: tabController.animation,
curve: Curves.fastOutSlowIn,
);
return new AnimatedBuilder(
animation: animation,
builder: (BuildContext context, Widget child) {
return new Semantics(
label: 'Page ${selection.index + 1} of ${selection.values.length}',
label: 'Page ${controller.index + 1} of ${controller.length}',
child: new Row(
children: selection.values.map((T tab) => _buildTabIndicator(selection, tab, animation, selectedColor, previousColor)).toList(),
mainAxisSize: MainAxisSize.min
)
mainAxisSize: MainAxisSize.min,
children: new List<Widget>.generate(controller.length, (int tabIndex) {
return _buildTabIndicator(tabIndex, controller, selectedColor, previousColor);
}).toList(),
),
);
}
);
......
......@@ -28,33 +28,70 @@ class StateMarkerState extends State<StateMarker> {
Widget buildFrame({ List<String> tabs, String value, bool isScrollable: false, Key tabBarKey }) {
return new Material(
child: new TabBarSelection<String>(
value: value,
values: tabs,
child: new TabBar<String>(
child: new DefaultTabController(
initialIndex: tabs.indexOf(value),
length: tabs.length,
child: new TabBar(
key: tabBarKey,
labels: new Map<String, TabLabel>.fromIterable(tabs, value: (String tab) => new TabLabel(text: tab)),
isScrollable: isScrollable
)
)
tabs: tabs.map((String tab) => new Tab(text: tab)).toList(),
isScrollable: isScrollable,
),
),
);
}
typedef Widget TabControllerFrameBuilder(BuildContext context, TabController controller);
class TabControllerFrame extends StatefulWidget {
TabControllerFrame({ this.length, this.initialIndex: 0, this.builder });
final int length;
final int initialIndex;
final TabControllerFrameBuilder builder;
@override
TabControllerFrameState createState() => new TabControllerFrameState();
}
class TabControllerFrameState extends State<TabControllerFrame> with SingleTickerProviderStateMixin {
TabController _controller;
@override
void initState() {
super.initState();
_controller = new TabController(
vsync: this,
length: config.length,
initialIndex: config.initialIndex,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return config.builder(context, _controller);
}
}
Widget buildLeftRightApp({ List<String> tabs, String value }) {
return new MaterialApp(
theme: new ThemeData(platform: TargetPlatform.android),
home: new TabBarSelection<String>(
value: value,
values: tabs,
home: new DefaultTabController(
initialIndex: tabs.indexOf(value),
length: tabs.length,
child: new Scaffold(
appBar: new AppBar(
title: new Text('tabs'),
bottom: new TabBar<String>(
labels: new Map<String, TabLabel>.fromIterable(tabs, value: (String tab) => new TabLabel(text: tab)),
)
bottom: new TabBar(
tabs: tabs.map((String tab) => new Tab(text: tab)).toList(),
),
),
body: new TabBarView<String>(
body: new TabBarView(
children: <Widget>[
new Center(child: new Text('LEFT CHILD')),
new Center(child: new Text('RIGHT CHILD'))
......@@ -70,83 +107,72 @@ void main() {
List<String> tabs = <String>['A', 'B', 'C'];
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C', isScrollable: false));
TabBarSelectionState<String> selection = TabBarSelection.of(tester.element(find.text('A')));
expect(selection, isNotNull);
expect(selection.indexOf('A'), equals(0));
expect(selection.indexOf('B'), equals(1));
expect(selection.indexOf('C'), equals(2));
expect(find.text('A'), findsOneWidget);
expect(find.text('B'), findsOneWidget);
expect(find.text('C'), findsOneWidget);
expect(selection.index, equals(2));
expect(selection.previousIndex, equals(2));
expect(selection.value, equals('C'));
expect(selection.previousValue, equals('C'));
TabController controller = DefaultTabController.of(tester.element(find.text('A')));
expect(controller, isNotNull);
expect(controller.index, 2);
expect(controller.previousIndex, 2);
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C' ,isScrollable: false));
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C', isScrollable: false));
await tester.tap(find.text('B'));
await tester.pump();
expect(selection.valueIsChanging, true);
expect(controller.indexIsChanging, true);
await tester.pump(const Duration(seconds: 1)); // finish the animation
expect(selection.valueIsChanging, false);
expect(selection.value, equals('B'));
expect(selection.previousValue, equals('C'));
expect(selection.index, equals(1));
expect(selection.previousIndex, equals(2));
expect(controller.index, 1);
expect(controller.previousIndex, 2);
expect(controller.indexIsChanging, false);
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C', isScrollable: false));
await tester.tap(find.text('C'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(selection.value, equals('C'));
expect(selection.previousValue, equals('B'));
expect(selection.index, equals(2));
expect(selection.previousIndex, equals(1));
expect(controller.index, 2);
expect(controller.previousIndex, 1);
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C', isScrollable: false));
await tester.tap(find.text('A'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(selection.value, equals('A'));
expect(selection.previousValue, equals('C'));
expect(selection.index, equals(0));
expect(selection.previousIndex, equals(2));
expect(controller.index, 0);
expect(controller.previousIndex, 2);
});
testWidgets('Scrollable TabBar tap selects tab', (WidgetTester tester) async {
List<String> tabs = <String>['A', 'B', 'C'];
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C', isScrollable: true));
TabBarSelectionState<String> selection = TabBarSelection.of(tester.element(find.text('A')));
expect(selection, isNotNull);
expect(find.text('A'), findsOneWidget);
expect(find.text('B'), findsOneWidget);
expect(find.text('C'), findsOneWidget);
expect(selection.value, equals('C'));
TabController controller = DefaultTabController.of(tester.element(find.text('A')));
expect(controller.index, 2);
expect(controller.previousIndex, 2);
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C', isScrollable: true));
await tester.tap(find.text('B'));
await tester.tap(find.text('C'));
await tester.pump();
expect(selection.value, equals('B'));
await tester.pump(const Duration(seconds: 1));
expect(controller.index, 2);
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C', isScrollable: true));
await tester.tap(find.text('C'));
await tester.tap(find.text('B'));
await tester.pump();
expect(selection.value, equals('C'));
await tester.pump(const Duration(seconds: 1));
expect(controller.index, 1);
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C', isScrollable: true));
await tester.tap(find.text('A'));
await tester.pump();
expect(selection.value, equals('A'));
await tester.pump(const Duration(seconds: 1));
expect(controller.index, 0);
});
testWidgets('Scrollable TabBar tap centers selected tab', (WidgetTester tester) async {
List<String> tabs = <String>['AAAAAA', 'BBBBBB', 'CCCCCC', 'DDDDDD', 'EEEEEE', 'FFFFFF', 'GGGGGG', 'HHHHHH', 'IIIIII', 'JJJJJJ', 'KKKKKK', 'LLLLLL'];
Key tabBarKey = new Key('TabBar');
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'AAAAAA', isScrollable: true, tabBarKey: tabBarKey));
TabBarSelectionState<String> selection = TabBarSelection.of(tester.element(find.text('AAAAAA')));
expect(selection, isNotNull);
expect(selection.value, equals('AAAAAA'));
TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA')));
expect(controller, isNotNull);
expect(controller.index, 0);
expect(tester.getSize(find.byKey(tabBarKey)).width, equals(800.0));
// The center of the FFFFFF item is to the right of the TabBar's center
......@@ -155,7 +181,7 @@ void main() {
await tester.tap(find.text('FFFFFF'));
await tester.pump();
await tester.pump(const Duration(seconds: 1)); // finish the scroll animation
expect(selection.value, equals('FFFFFF'));
expect(controller.index, 5);
// The center of the FFFFFF item is now at the TabBar's center
expect(tester.getCenter(find.text('FFFFFF')).x, closeTo(400.0, 1.0));
});
......@@ -165,9 +191,9 @@ void main() {
List<String> tabs = <String>['AAAAAA', 'BBBBBB', 'CCCCCC', 'DDDDDD', 'EEEEEE', 'FFFFFF', 'GGGGGG', 'HHHHHH', 'IIIIII', 'JJJJJJ', 'KKKKKK', 'LLLLLL'];
Key tabBarKey = new Key('TabBar');
await tester.pumpWidget(buildFrame(tabs: tabs, value: 'AAAAAA', isScrollable: true, tabBarKey: tabBarKey));
TabBarSelectionState<String> selection = TabBarSelection.of(tester.element(find.text('AAAAAA')));
expect(selection, isNotNull);
expect(selection.value, equals('AAAAAA'));
TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA')));
expect(controller, isNotNull);
expect(controller.index, 0);
// Fling-scroll the TabBar to the left
expect(tester.getCenter(find.text('HHHHHH')).x, lessThan(700.0));
......@@ -177,31 +203,26 @@ void main() {
expect(tester.getCenter(find.text('HHHHHH')).x, lessThan(500.0));
// Scrolling the TabBar doesn't change the selection
expect(selection.value, equals('AAAAAA'));
expect(controller.index, 0);
});
testWidgets('TabView maintains state', (WidgetTester tester) async {
testWidgets('TabBarView maintains state', (WidgetTester tester) async {
List<String> tabs = <String>['AAAAAA', 'BBBBBB', 'CCCCCC', 'DDDDDD', 'EEEEEE'];
String value = tabs[0];
void onTabSelectionChanged(String newValue) {
value = newValue;
}
Widget builder() {
return new Material(
child: new TabBarSelection<String>(
value: value,
values: tabs,
onChanged: onTabSelectionChanged,
child: new TabBarView<String>(
child: new DefaultTabController(
initialIndex: tabs.indexOf(value),
length: tabs.length,
child: new TabBarView(
children: tabs.map((String name) {
return new StateMarker(
child: new Text(name)
);
}).toList()
)
)
),
),
);
}
......@@ -210,6 +231,8 @@ void main() {
}
await tester.pumpWidget(builder());
TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA')));
TestGesture gesture = await tester.startGesture(tester.getCenter(find.text(tabs[0])));
await gesture.moveBy(const Offset(-600.0, 0.0));
await tester.pump();
......@@ -218,6 +241,7 @@ void main() {
await gesture.up();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
value = tabs[controller.index];
expect(value, equals(tabs[1]));
await tester.pumpWidget(builder());
expect(findStateMarkerState(tabs[1]).marker, equals('marked'));
......@@ -230,6 +254,7 @@ void main() {
await tester.pump();
expect(findStateMarkerState(tabs[1]).marker, equals('marked'));
await tester.pump(const Duration(seconds: 1));
value = tabs[controller.index];
expect(value, equals(tabs[2]));
await tester.pumpWidget(builder());
......@@ -248,6 +273,7 @@ void main() {
await gesture.up();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
value = tabs[controller.index];
expect(value, equals(tabs[1]));
await tester.pumpWidget(builder());
expect(findStateMarkerState(tabs[1]).marker, equals('marked'));
......@@ -262,15 +288,15 @@ void main() {
expect(find.text('LEFT CHILD'), findsOneWidget);
expect(find.text('RIGHT CHILD'), findsNothing);
TabBarSelectionState<String> selection = TabBarSelection.of(tester.element(find.text('LEFT')));
expect(selection.value, equals('LEFT'));
TabController controller = DefaultTabController.of(tester.element(find.text('LEFT')));
expect(controller.index, 0);
// Fling to the left, switch from the 'LEFT' tab to the 'RIGHT'
Point flingStart = tester.getCenter(find.text('LEFT CHILD'));
await tester.flingFrom(flingStart, const Offset(-200.0, 0.0), 10000.0);
await tester.pump();
await tester.pump(const Duration(seconds: 1)); // finish the scroll animation
expect(selection.value, equals('RIGHT'));
expect(controller.index, 1);
expect(find.text('LEFT CHILD'), findsNothing);
expect(find.text('RIGHT CHILD'), findsOneWidget);
......@@ -279,7 +305,7 @@ void main() {
await tester.flingFrom(flingStart, const Offset(200.0, 0.0), 10000.0);
await tester.pump();
await tester.pump(const Duration(seconds: 1)); // finish the scroll animation
expect(selection.value, equals('LEFT'));
expect(controller.index, 0);
expect(find.text('LEFT CHILD'), findsOneWidget);
expect(find.text('RIGHT CHILD'), findsNothing);
});
......@@ -294,8 +320,8 @@ void main() {
expect(find.text('LEFT CHILD'), findsOneWidget);
expect(find.text('RIGHT CHILD'), findsNothing);
TabBarSelectionState<String> selection = TabBarSelection.of(tester.element(find.text('LEFT')));
expect(selection.value, equals('LEFT'));
TabController controller = DefaultTabController.of(tester.element(find.text('LEFT')));
expect(controller.index, 0);
// End the fling by reversing direction. This should cause not cause
// a change to the selected tab, everything should just settle back to
......@@ -304,7 +330,7 @@ void main() {
await tester.flingFrom(flingStart, const Offset(-200.0, 0.0), -10000.0);
await tester.pump();
await tester.pump(const Duration(seconds: 1)); // finish the scroll animation
expect(selection.value, equals('LEFT'));
expect(controller.index, 0);
expect(find.text('LEFT CHILD'), findsOneWidget);
expect(find.text('RIGHT CHILD'), findsNothing);
});
......@@ -321,17 +347,17 @@ void main() {
child: new SizedBox(
width: 300.0,
height: 200.0,
child: new TabBarSelection<String>(
values: tabs,
child: new DefaultTabController(
length: tabs.length,
child: new Scaffold(
appBar: new AppBar(
title: new Text('tabs'),
bottom: new TabBar<String>(
bottom: new TabBar(
isScrollable: true,
labels: new Map<String, TabLabel>.fromIterable(tabs, value: (String tab) => new TabLabel(text: tab)),
tabs: tabs.map((String tab) => new Tab(text: tab)).toList(),
),
),
body: new TabBarView<String>(
body: new TabBarView(
children: tabs.map((String name) => new Text('${index++}')).toList(),
),
),
......@@ -348,4 +374,175 @@ void main() {
final RenderBox box = tester.renderObject(find.text('BBBBBB'));
expect(box.localToGlobal(Point.origin).x, greaterThan(0.0));
});
testWidgets('TabController change notification', (WidgetTester tester) async {
List<String> tabs = <String>['LEFT', 'RIGHT'];
await tester.pumpWidget(buildLeftRightApp(tabs: tabs, value: 'LEFT'));
TabController controller = DefaultTabController.of(tester.element(find.text('LEFT')));
expect(controller, isNotNull);
expect(controller.index, 0);
String value;
controller.addListener(() {
value = tabs[controller.index];
});
// TODO(hixie) - the new scrolling framework should eliminate most of the pump
// calls that follow. Currently they exist to complete chains of future.then
// in the implementation.
await tester.tap(find.text('RIGHT'));
await tester.pump(); // start the animation
await tester.pump(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 500));
expect(value, 'RIGHT');
await tester.tap(find.text('LEFT'));
await tester.pump(); // start the animation
await tester.pump(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 500));
expect(value, 'LEFT');
Point leftFlingStart = tester.getCenter(find.text('LEFT CHILD'));
await tester.flingFrom(leftFlingStart, const Offset(-200.0, 0.0), 10000.0);
await tester.pump(); // start the animation
await tester.pump(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 500));
expect(value, 'RIGHT');
Point rightFlingStart = tester.getCenter(find.text('RIGHT CHILD'));
await tester.flingFrom(rightFlingStart, const Offset(200.0, 0.0), 10000.0);
await tester.pump(); // start the animation
await tester.pump(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 500));
expect(value, 'LEFT');
});
testWidgets('Explicit TabController', (WidgetTester tester) async {
List<String> tabs = <String>['LEFT', 'RIGHT'];
TabController tabController;
Widget buildTabControllerFrame(BuildContext context, TabController controller) {
tabController = controller;
return new MaterialApp(
theme: new ThemeData(platform: TargetPlatform.android),
home: new Scaffold(
appBar: new AppBar(
title: new Text('tabs'),
bottom: new TabBar(
controller: controller,
tabs: tabs.map((String tab) => new Tab(text: tab)).toList(),
),
),
body: new TabBarView(
controller: controller,
children: <Widget>[
new Center(child: new Text('LEFT CHILD')),
new Center(child: new Text('RIGHT CHILD'))
]
),
),
);
}
await tester.pumpWidget(new TabControllerFrame(
builder: buildTabControllerFrame,
length: tabs.length,
initialIndex: 1,
));
expect(find.text('LEFT'), findsOneWidget);
expect(find.text('RIGHT'), findsOneWidget);
expect(find.text('LEFT CHILD'), findsNothing);
expect(find.text('RIGHT CHILD'), findsOneWidget);
expect(tabController.index, 1);
expect(tabController.previousIndex, 1);
expect(tabController.indexIsChanging, false);
expect(tabController.animation.value, 1.0);
expect(tabController.animation.status, AnimationStatus.completed);
tabController.index = 0;
await tester.pump(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('LEFT CHILD'), findsOneWidget);
expect(find.text('RIGHT CHILD'), findsNothing);
tabController.index = 1;
await tester.pump(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('LEFT CHILD'), findsNothing);
expect(find.text('RIGHT CHILD'), findsOneWidget);
});
testWidgets('TabController listener resets index', (WidgetTester tester) async {
// This is a regression test for the scenario brought up here
// https://github.com/flutter/flutter/pull/7387#pullrequestreview-15630946
List<String> tabs = <String>['A', 'B', 'C'];
TabController tabController;
Widget buildTabControllerFrame(BuildContext context, TabController controller) {
tabController = controller;
return new MaterialApp(
theme: new ThemeData(platform: TargetPlatform.android),
home: new Scaffold(
appBar: new AppBar(
title: new Text('tabs'),
bottom: new TabBar(
controller: controller,
tabs: tabs.map((String tab) => new Tab(text: tab)).toList(),
),
),
body: new TabBarView(
controller: controller,
children: <Widget>[
new Center(child: new Text('CHILD A')),
new Center(child: new Text('CHILD B')),
new Center(child: new Text('CHILD C')),
]
),
),
);
}
await tester.pumpWidget(new TabControllerFrame(
builder: buildTabControllerFrame,
length: tabs.length,
));
tabController.animation.addListener(() {
if (tabController.animation.status == AnimationStatus.forward)
tabController.index = 2;
expect(tabController.indexIsChanging, true);
});
expect(tabController.index, 0);
expect(tabController.indexIsChanging, false);
tabController.animateTo(1, duration: const Duration(milliseconds: 200), curve: Curves.linear);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(tabController.index, 2);
expect(tabController.indexIsChanging, false);
});
testWidgets('TabBarView child disposed during animation', (WidgetTester tester) async {
// This is a regression test for the scenario brought up here
// https://github.com/flutter/flutter/pull/7387#discussion_r95089191x
List<String> tabs = <String>['LEFT', 'RIGHT'];
await tester.pumpWidget(buildLeftRightApp(tabs: tabs, value: 'LEFT'));
// Fling to the left, switch from the 'LEFT' tab to the 'RIGHT'
Point flingStart = tester.getCenter(find.text('LEFT CHILD'));
await tester.flingFrom(flingStart, const Offset(-200.0, 0.0), 10000.0);
await tester.pump();
await tester.pump(const Duration(seconds: 1)); // finish the scroll animation
});
}
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