A SegmentedWidget for Flutter
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.