Segmented control vs TabBar in Flutter
Here I offer a recipie. It is a little sample of how to make a control that shows up as a segmented control on iOS and a tab control in Android.
One of the main things that has interested me about Flutter is how it sets out to deal with deploying to multiple devices and platforms with a single code base. How much code is resusable? How do you logically structure it to run through the same methods, but adhere to the standards expected of each platform?
One of the libraries I’ve started taking a look at is the flutter_platform_widgets package, which attempts to derive a pattern for dealing with the differences between screen elements on the iOS and Android platforms.
Sure, it is possible to use Material Design on both Android and iOS, however flutter does include a host of Cupertino widgets to give the look of iOS.
The framework flutter_platform_widgets provides a pattern for implementing custom UI logic in the following way:
PlatformText(
"Some text to display",
ios: (_) => CupertinoTextFieldData(),
android: (_) => MaterialTextFieldData()
);
Just use the *Data objects to pass in platform-specific configuration.
However not every UI element is as clearly encapsulated. There may be other times a need arises for similar encapsulation however the UI elements being brought together aren’t quite as related. Segmented controls vs TabViews are one such instance. The segmented control is an iOS-type of control. See the photos below for how an app might translate the function of a segmented control (on the right) to an Android device using a TabBar.
Note:
In addition to showing a recipie for choosing between a segmented control or a TabBar, this post also demonstrates the use of Providers for managing the UI state and that may make it a bit different from other examples out there demonstrating tab-based controls in flutter. If you’re new to providers, or would like so see an example that focuses solely on a provider implementation, see an old post here.
Let’s take a look at the code. The following shows the build method from the MyHomePage stateless widget. The full example source code is here.
main.dart
@override
Widget build(BuildContext context) {
_loadResults(context);
return DefaultTabController(
length: 3,
child: PlatformScaffold(
appBar: PlatformAppBar(
android: (_) => MaterialAppBarData(
backgroundColor: Colors.white,
title: Center(
child: PlatformText(
"Material Tab Bar",
style: TextStyle(color: Colors.black),
)),
bottom: TabBar(
indicatorColor: Colors.black,
tabs: _tabs(),
onTap: (value) {
_onMaterialTabTapped(context, value);
},
)),
ios: (_) => CupertinoNavigationBarData(
title: PlatformText("iOS Segmented Control")),
),
body: SafeArea(
child: Padding(
padding: EdgeInsets.all(8), child: _showTabView(context))),
// ),
));
}
This code represents a mesh point between the two platforms. The root widget is a DefaultTabController which is only used by the material app. The biggest difference in this example is that to get the look shown above in the images, all the iOS controls, the segmented control and associated page views, are part of the screen’s main body. On the material app, the tab bar is configured as part of the AppBar. This pattern defers to each PlatformWidget class to do the appropriate thing for the platform without needing to surface a Platform.isIOS == true
call.
The DefaultTabController doesn’t have to be the root element, but it’s convienent. It handles transitioning the TabBar state, keeping track of things like making sure the underline gets drawn under the selected tab. The TabBar can take a tabcontroller:
if the DefaultTabController doesn’t fit the requirement. Then it’s up to that implementation to keep track of the tab state and what things look like when a tab is selected.
Here’s how the segmented control is set up in the body, a call to the Platform is necessary here in order to conditionally show the segmented control. The segmented control could be added to the app bar through the CupertinoNavigationData, which would make using the Platform call here uncessary. But then segmented control would be in the AppBar instead of where the material tabs are placed. Depending on the app, maybe that is ok. It is NOT for this one. ;)
Heres showTabView(), which displays the screen’s main content.
dynamic _showTabView(BuildContext context) {
if (Platform.isIOS) {
var currentState = Provider.of<TabDisplayState>(context).currentState;
currentState = currentState != null ? currentState : ResultType.TYPE_A;
return Column(children: <Widget>[
CupertinoSegmentedControl(
borderColor: CupertinoColors.black,
groupValue: currentState,
selectedColor: CupertinoColors.black,
children: _cupertinoTabs(),
onValueChanged: (value) =>
Provider.of<TabDisplayState>(context).setCurrentState(value)),
_divider(context),
_tabList()
]);
} else {
//material
return _tabList();
}
This is using Provider to get the currentState, which is used to set the selected tab through the groupValue:
parameter. When the user selects another tab, the onValueChanged
event fires and sets the current state to the value associated with the selected tab. The valid values are stored as keys in a map. This is the map used to populate the contents of the segmented control’s buttons. It is a map of type <dynamic, Widget>
. Here is the tab mapping for this project, passed in as children:
to the segmented control.
dynamic _cupertinoTabs() {
return {
ResultType.TYPE_A: _tabs()[0],
ResultType.TYPE_B: _tabs()[1],
ResultType.TYPE_C: _tabs()[2]
};
}
List<Widget> _tabs() {
TextStyle style = TextStyle(
fontSize: 24,
color: Platform.isIOS == true ? CupertinoColors.black : Colors.black);
var tabA = PlatformText("Type A", style: style);
var tabB = PlatformText("Type B", style: style);
var tabC = PlatformText("Type C", style: style);
final double inset = 12.0;
List<Widget> iosTabs = [
Padding(padding: EdgeInsets.all(inset), child: tabA),
Padding(padding: EdgeInsets.all(inset), child: tabB),
Padding(padding: EdgeInsets.all(inset), child: tabC),
];
List<Tab> materialTabs = [
Tab(child: tabA),
Tab(child: tabB),
Tab(child: tabC)
];
return Platform.isIOS == true ? iosTabs : materialTabs;
}
One thing to notice is the return value of _tabs() is based on Platform. The material app just wants a list of Tab objects and hands those over to the tab controller. Also note _onMaterialTabTapped, which, as you might expect, sets the provider’s currentState property. In the material implementation, the DefaultTabController handles setting the mapping of a tab’s key value to a widget.
bottom: TabBar(
indicatorColor: Colors.black,
tabs: _tabs(),
onTap: (value) {
_onMaterialTabTapped(context, value);
},
)
Setting the state with provider is the key. When the provider data changes, the widgets listening to that provider for changes redraw according to the new data. In this case there is a TabPage and that tab page shows a list of items. This list changes depending on the tab state.
The TabPage is made available through the _tabList() method, which set up the tab page as a consumer, listening to the provider data for changes.
Widget _tabList() {
return Column(children: <Widget>[
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Consumer<TabDisplayState>(
builder: (context, display, _) => _tabPage(display)),
),
)
]);
}
Here is the TabPage.
class TabPage extends StatelessWidget {
final ResultType currentState;
final List<Result> results;
TabPage(this.currentState, this.results);
@override
Widget build(BuildContext context) {
return this.results == null || this.currentState == null
? Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: results.length,
itemBuilder: (context, index) {
TextStyle style = TextStyle(
fontSize: 24,
);
return PlatformText(results[index].resultData, style: style);
},
shrinkWrap: true,
);
}
This code could definately be refactored and encapsulated a bit more, but as far as using the same codebase to provide different characteristics according to platform, flutter offers a pretty good paradigm.