A SegmentedWidget for Flutter

Stefan Matthias Aust
ICNH
Published in
8 min readApr 20, 2019
the widget I will create in this article

A segmented control is an iOS specific UI element that basically is a horizontal row of radio buttons. Material Design defines toggle button groups which work similar but have a different visual appearance.

Let’s create a SegmentedWidget in Flutter. And yes, I know there is already a CupertinoSegmentedControl widget available. But (re)creating widgets is a nice learning experience and I invite you to read along.

My widget shall use the MaterialTheme and therefore work seamlessly in a material design themed app. It will display a list of child widgets surrounded by a rounded border (we might later decide to make it customizable). The border will automatically pick up the accent color from the theme (again, we can later think about customization) as will the texts and icons. One of the children can be displayed in selected state, that is the accent color becomes the background color and texts and icons change accordingly. I think, the accent text theme is either white or black, depending on the brightness of the accent color.

Let’s start with a simple test bed:

import 'package:flutter/material.dart';void main() => runApp(MyApp());class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
accentColor: Colors.deepOrange,
),
home: TestPage(),
);
}
}

The TestPage is a stateful widget because I need to keep track of the selection index – later. For my first attempt, I hard-code it. To test my implementation, I define a Text, an Icon, a more complex widget using a Column, and an Image. That should cover most usecases, I hope.

class TestPage extends StatefulWidget {
@override
_TestPageState createState() => _TestPageState();
}
class _TestPageState extends State<TestPage> {
int _index;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(16),
child: SegmentedWidget(
children: [
Text('Text'),
Icon(Icons.thumb_up, size: 32),
Column(
children: <Widget>[
Text('Hello', textScaleFactor: 0.8),
Text('World', textScaleFactor: 1.25),
],
),
FlutterLogo(),
],
),
),
);
}
}

Here is my minimal implementation of SegmentedWidget:

class SegmentedWidget extends StatelessWidget {
const SegmentedWidget({
Key key,
this.children,
}) : super(key: key);
final List<Widget> children; @override
Widget build(BuildContext context) {
final color = Theme.of(context).accentColor;
return Container(
color: color,
child: Row(
children: children,
),
);
}
}

Unfortauntely, it gets a completely wrong size (demonstrated by the orange background) because the Column child is too greedy and graps every bit of height it can get:

My way to solve this is to wrap the Row with an IntrinsicHeight widget, because I don’t want to force the user of my widget to use MainAxisSize.min on her Column widgets.

Much better:

Let’s round the Container by adding a ShapeDecoration with rounded edges. I could also use a BoxDecoration but if I want to allow any kind of shape (as expressible by a ShapeBorder), it will be easier to configure this way.

I also need to specify the border color, which I extract from the current theme:

  Widget build(BuildContext context) {
final color = Theme.of(context).accentColor;
return Container(
decoration: ShapeDecoration(
color: color,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: IntrinsicHeight(
child: Row(
children: children,
),
),
);
}

And there are the rounded borders – nice.

Next, I will distribute the children evenly and add lines between the children and a bit of padding for each child. Children should also be centered. Because I need to add quite a few other widgets to those children, I use a new method _buildChildren to do all this in its own method instead of inside build:

  Widget build(BuildContext context) {
final color = Theme.of(context).accentColor;
return Container(
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(color: color),
borderRadius: BorderRadius.circular(8),
),
),
child: IntrinsicHeight(
child: Row(
children: [..._buildChildren(color)],
),
),
);
}
Iterable<Widget> _buildChildren(Color color) sync* {
for (int i = 0; i < children.length; i++) {
if (i > 0) {
yield Container(
color: color,
width: 1,
);
}
yield Expanded(
child: Container(
color: i == 1 ? color : null,
padding: EdgeInsets.all(4),
alignment: Alignment.center,
child: children[i],
),
);
}
}

It’s starting to look as I want it to look:

Unfortunately, “everything” breaks if I change the selection index to 0.

By default, child widgets can paint over the visible borders of their parents and I have to add a ClipPath widget to restrict them to the shape I want them to draw into. So we need to refactor the build method once more:

  Widget build(BuildContext context) {
final color = Theme.of(context).accentColor;
final shape = RoundedRectangleBorder(
side: BorderSide(color: color),
borderRadius: BorderRadius.circular(8),
);
return ClipPath(
clipper: ShapeBorderClipper(shape: shape),
child: Container(
foregroundDecoration: ShapeDecoration(
shape: shape,
),
child: IntrinsicHeight(
child: Row(
children: [..._buildChildren(color)],
),
),
),
);
}

Next, let’s fix the text and icon color.

For text, I will wrap children with a DefaultTextStyle which is then picked up automatically. For icons, an IconTheme widget will do the same trick. If the child is selected, however, I need to use a different text which I will extract from the theme like so:

  Iterable<Widget> _buildChildren(BuildContext context) sync* {
final theme = Theme.of(context);
final color = theme.accentColor;
final textColor = theme.accentTextTheme.button.color;
final style1 = theme.textTheme.button.copyWith(color: color);
final style2 = theme.textTheme.button.copyWith(color:textColor);
final data1 = theme.iconTheme.copyWith(color: color);
final data2 = theme.iconTheme.copyWith(color: textColor);
final duration = kThemeAnimationDuration;
for (int i = 0; i < children.length; i++) {
if (i > 0) {
yield Container(
color: color,
width: 1,
);
}
final selected = i == 0;
yield Expanded(
child: Container(
color: selected ? color : null,
padding: EdgeInsets.all(4),
alignment: Alignment.center,
child: DefaultTextStyle(
style: selected ? style2 : style1,
child: IconTheme(
data: selected ? data2 : data1,
child: children[i],
),
),
),
);
}
}

Last but not least, the widget shall be interactive. I add an index and an onChanged property to the widget. The first defines the currently selected index. The latter is called if the user selects a child widget.

class SegmentedWidget extends StatelessWidget {
const SegmentedWidget({
Key key,
@required this.onChanged,
this.index,
this.children,
}) : super(key: key);
final ValueChanged<int> onChanged;
final int index;
final List<Widget> children;
... Iterable<Widget> _buildChildren(BuildContext context) sync* {
...
for (int i = 0; i < children.length; i++) {
...
final selected = i == index;
yield Expanded(
child: GestureDetector(
onTap: onChanged != null ? () => onChanged(i) : null,
child: ...
),
);
}
}
}

And this is how the SegmentedWidget is used in TestPage:

        SegmentedWidget(
onChanged: (i) {
setState(() => _index = _index == i ? null : i);
},
index: _index,
children: [
...
],
),

For my use case, I needed the behavior that if tapping a selected child again, it will be deselected, so I used this as an example here. If you don’t want this behavior, simply assign the i without the conditional test.

Apropos tapping, you might notice that only the child widget reacts to taps, not the whole Container which surrounds the child and which itself is the child of the GestureDetector. My workaround is to never use null for the color (which somehow makes the Container to not take part in the hit test process) but use color.withAlpha(0).

Now everything works as expected but I’d like to take this widget to the next level. Let’s animate the transitions. If you change for example the theme color of any MateralApp, you might notice that the change happens gradually. This is due to an AnimatedTheme widget which animates the change over 200 ms.

By replacing the normal Container with an AnimatedContainer we’re almost there:

  Iterable<Widget> _buildChildren(BuildContext context) sync* {
...
final duration = Duration(milliseconds: 200);
for (int i = 0; i < children.length; i++) {
...
yield Expanded(
child: GestureDetector(
onTap: onChanged != null ? () => onChanged(i) : null,
child: AnimatedContainer(
duration: duration,
color: selected ? color : color.withAlpha(0),
...
),
),
);
}
}

If you look closely (or activate slow animations) I notice that the text (or icon) color now changes immediately while the background color changes slowly. To also change the text color, we can replace the DefaultTextStyle with an AnimatedDefaultTextStyle but unfortunately, there’s no AnimatedIconTheme as far as I know.

Therefore, I decided to use an AnimatedTheme here, even if that might be overkill. I don’t know. Unfortunately, the AnimatedTheme doesn’t support custom duration, so I have to use kThemeAnimationDuration instead to make everything use the same duration.

Here’s my final code:

class SegmentedWidget extends StatelessWidget {
const SegmentedWidget({
Key key,
@required this.onChanged,
this.index,
this.children,
}) : super(key: key);
final ValueChanged<int> onChanged;
final int index;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).accentColor;
final shape = RoundedRectangleBorder(
side: BorderSide(color: color),
borderRadius: BorderRadius.circular(8),
);
return ClipPath(
clipper: ShapeBorderClipper(shape: shape),
child: Container(
foregroundDecoration: ShapeDecoration(
shape: shape,
),
child: IntrinsicHeight(
child: Row(
children: [..._buildChildren(context)],
),
),
),
);
}
Iterable<Widget> _buildChildren(BuildContext context) sync* {
final theme = Theme.of(context);
final color = theme.accentColor;
final textColor = theme.accentTextTheme.button.color;
final style1 = theme.textTheme.button.copyWith(color: color);
final style2 = theme.textTheme.button.copyWith(color:textColor);
final data1 = theme.iconTheme.copyWith(color: color);
final data2 = theme.iconTheme.copyWith(color: textColor);
final duration = kThemeAnimationDuration;
for (int i = 0; i < children.length; i++) {
if (i > 0) {
yield Container(
color: color,
width: 1,
);
}
final selected = i == index;
yield Expanded(
child: GestureDetector(
onTap: onChanged != null ? () => onChanged(i) : null,
child: AnimatedContainer(
duration: duration,
color: selected ? color : color.withAlpha(0),
padding: EdgeInsets.all(4),
alignment: Alignment.center,
child: AnimatedTheme(
data: theme.copyWith(
iconTheme: selected ? data2 : data1,
),
child: AnimatedDefaultTextStyle(
duration: duration,
style: selected ? style2 : style1,
child: children[i],
),
),
),
),
);
}
}
}

Or get this project from github.

Thanks for reading this article and please check out my other articles, too.

--

--

Stefan Matthias Aust
ICNH
Editor for

App Developer · Co-Founder of I.C.N.H GmbH · Pen & paper role player