Setting up a Flutter Widget Library
Part 5 in my “Flutter from a complete beginner” series.
Tiger asks … where do I keep my widgets?
I don’t like re-inventing the wheel. And from my experience with e.g. Vaadin, I know it pays to have a library of re-usable building blocks with which I can compose my more specific UIs in the application.
I’d like a package separate from my application, where I can keep my reusable widgets.
Then to test it, we’ll create our first ever StatefulWidget
and use a controller to talk to it.
Setting up a widget library
I could just create a subdirectory lib/src/library
, but there’s several benefits to not doing so. For example:
- I could want to push that library to a git repository, at some point, for re-use in other projects
- it forces me to keep the widgets I add to the library oblivious of business logic
- I can add some widget cataloging feature to the package without affecting my application
So now that we know how packages and dependencies work, let’s do something more intelligent and set up a widgetbook.
Creating our widgetbook
flutter create --template=package widgets
This creates a small flutter package:
After adjusting the README.md
and the description in pubspec.yaml
, let’s
cd widgets && flutter pub upgrade --major-versions
With our package thus in place, first we need the widgetbook dependency:
flutter pub add dev:widgetbook_annotation dev:widgetbook dev:widgetbook_generator dev:build_runner
What’s all this, then?
dev:widgetbook
is the core library, this would be sufficient if we were fine with manually catalogueing our components (cf. their demo)dev:widgetbook_annotation
adds annotations, that we can use to skip some of the manual labourdev:widgetbook_generator
then takes these annotations and generates the code that we would have to add ourselvesdev:build_runner
is necessary to run the generator
and we add all of these as dev:
because we only need them within the package. Anybody using our widgets should only need whatever the widgets themselves need.
With this, we can now delete the lib/widgets.dart
and create a a minimal widgetbook app in lib/src/widgetbook_app.dart
.
The reason why we move our app to lib/src
is again that we don’t want to export our widgetbook app.
import 'package:flutter/material.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
import 'widgetbook_app.directories.g.dart' show directories;
@widgetbook.App()
class WidgetBookApp extends StatelessWidget {
const WidgetBookApp({super.key});
@override
Widget build(BuildContext context) {
return Widgetbook.material(
// Use the generated directories variable
directories: directories,
addons: const [],
integrations: const [],
);
}
}
widgetbook_app.directories.g.dart
doesn’t yet exist. As the .g.
informs us, it’s generated by the build_runner
. So let’s generate it.
In the widgets/
subdirectory of our project, run
flutter pub run build_runner watch
watch
makes it so build_runner
automatically updates if it detects changes in the working files. If you just want to run a singular build, replace it with build
, instead.
To make it simple, I’ll define IntelliJ run configurations for these:
You may want to opt-out of widgetbook’s telemetry, though. Just copy this into a build.yaml
file:
targets:
$default:
builders:
widgetbook_generator:telemetry:
enabled: false
Now we can run the app:
And we’ll run that in a browser.
A first run informed me, that flutter was using material design even though it does not declare it in the pubspec.yaml
, and that led to some display issues, but after adding it:
The app displays without issues.
But there’s no widget to display, so it’s a bit boring.
Creating our first widget
Fun feature I found out about by chance:
If you type
stless
, IntelliJ will offer you to generate the boilerplate code for aStatelessWidget
.If you type
stful
, it will offer the boilerplate for aStatefulWidget
.
and I’m going to immediately make use of this to test my library.
(Shoutout to Bhaumik for her post about a couple of other neat features that come with IntelliJ / Android Studio.)
Note:
Widgetbook features a component and use-case approach in which a single component [e.g. button] has one or multiple use-cases [e.g. TextButton, ConfirmButton, …].
A use-case might resemble a variant of a component or just a specific state of the component.
So our first component is going to be simple: a message panel, with use cases “info”, “warning” and “error” to be displayed in blue, yellow and red, respectively.
(I won’t go into how to dart. There’s a cheat sheet for that.)
Where to start?
First, https://docs.flutter.dev/ui/widgets is a great place to get an overview of what flutter comes with out of the box and to search for widgets when you’re looking to implement something. In this case, we’ll mainly be making use of two basic widgets:
Let’s use stful
to generate a StatefulWidget
in new file widgets/lib/text/infopanel.dart
.
In Widgetbook, developers create a folder tree structure to organize and catalog components.
So I’m assuming our info panel will end up showing in the “text” component section.
Right away, we’re introduced to an import question:
Both flutter/material
and flutter/cupertino
packages export flutter/widgets
… Let’s go with flutter/widgets
, for now.
In a first iteration, let’s just display a Text
:
import 'package:flutter/widgets.dart'
show BuildContext, State, StatefulWidget, Text, Widget;
import 'messagelevel.dart';
// TODO: display use cases in widgetbook
class InfoPanel extends StatefulWidget {
final MessageLevel level;
final String message;
const InfoPanel({
super.key,
this.level = MessageLevel.info,
this.message = '<placeholder message>',
});
@override
State<InfoPanel> createState() => _InfoPanelState();
}
class _InfoPanelState extends State<InfoPanel> {
late MessageLevel _level;
late String _message;
@override
void initState() {
assert(widget.message.isNotEmpty, 'InfoPanel message must not be empty.');
_level = widget.level;
_message = widget.message;
super.initState();
}
@override
Widget build(BuildContext context) {
//TODO: use _level to determine style of panel
return Text(_message);
}
}
Where enum MessageLevel {info, warning, error}
.
Note
- this is a
StatefulWidget
so we can reuse it to display different messages without having to swap out theInfoPanel
widget itself - this is intentionally over-engineered, we could just create a
StatelessWidget
and be done with it. However, creating a State and strongly encapsulating its properties will allow us to talk about widget-to-widget communication later. - the widget constructor is
const
and all initial state values arefinal
, any modifications must go through the state object - the initial state set in the widget’s constructor are read from
State.widget
in theState.initState
function (remember the state life cycle from part 2 )
We’ll go into styling in a moment, but let’s first have a look into how to add them to the widget book.
Displaying the use cases in the widgetbook
I don’t really like the widgetbook documentation around this, so let me tell you plainly what it doesn’t:
- a “use case” is an instance of the widget you want to display
- to create a use case, create a function that takes a
BuildContext
and returns an instance of your widget
@UseCase("some name", MyWidget)
Widget someName(BuildContext context) => MyWidget(...)
"some-name"
is the display name widgetbook usesMyWidget
is what I’ll call the “group type”. AllMyWidget
use cases will be collected in one section of the widget book- the folder where
MyWidget
is located determines on which page of the widgetbook (which widgetbook calls “components”)
With that out of the way, let’s create use cases for our new InfoLabel
widget.
As I don’t want to export our widgetbook use cases to the main application, just the widget itself, I’ll add the use cases in a new widgets/src/text/infopanel_usecases.dart
file, relying on the convention that stuff placed in widgets/src
is private to the widgets/
package.
Let’s run the widgetbook application. (If you chose not to watch
your widgets earlier, remember to build the widgetbook, first.)
Looking good. But could look better.
Styling our widget
Theming is a big topic and I may dedicate a full article to it, at some point, but for now, we’ll just blindly use it and hope for the best:
- get
ThemeData
fromBuildContext
withTheme.of
- ask the retrieved
ThemeData
for styles - provide a back-up style if
ThemeData
does not deliver one - use
copyWith
to add your modifications before you add the style to your widgets
So for example, if I want to colour my text depending on the message level, you could do it like so:
Color get _messageColor => switch (_level) {
MessageLevel.info => Colors.blue,
MessageLevel.warning => Colors.orange,
MessageLevel.error => Colors.red,
};
TextStyle _messageStyle(ThemeData theme) =>
(theme.textTheme.bodyMedium ?? const TextStyle()).copyWith(
color: _messageColor,
);
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return Text(
_message,
style: _messageStyle(theme),
);
}
(I’m sorry about the lack of syntax highlighting, medium could do a better job, here…)
I also want a colourful box around my message, so let’s wrap it all into a Container
widget and style that, too. I’m going to use
EdgeInsets
for paddingBoxDecoration
for border and backgroundColor.withOpacity
to define my background colour
And I’m going to put all of that into a ThemeData
extension, so I don’t have to clutter up my state object with styling values.
Putting it all together, my state class will look like this:
class _InfoPanelState extends State<InfoPanel> {
late MessageLevel _level;
late String _message;
@override
void initState() {
assert(widget.message.isNotEmpty, 'InfoPanel message must not be empty.');
_level = widget.level;
_message = widget.message;
super.initState();
}
static const double _edgeInsets = 8;
static const double _borderRadius = 16;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(_edgeInsets),
decoration: BoxDecoration(
color: theme._backgroundColor(_level),
border: Border.all(color: theme._borderColor(_level)),
borderRadius: BorderRadius.circular(_borderRadius),
),
child: Text(
_message,
style: theme._messageStyle(_level),
),
);
}
}
extension on ThemeData {
Color _baseColor(MessageLevel level) => switch (level) {
MessageLevel.info => Colors.blue,
MessageLevel.warning => Colors.orange,
MessageLevel.error => colorScheme.error,
};
Color _messageColor(MessageLevel level) => _baseColor(level);
Color _borderColor(MessageLevel level) => _baseColor(level);
Color _backgroundColor(MessageLevel level) =>
_baseColor(level).withOpacity(0.1);
TextStyle _messageStyle(MessageLevel level) =>
(textTheme.bodyMedium ?? const TextStyle()).copyWith(
color: _messageColor(level),
);
}
And I’m quite happy with how that ends up looking in the widgetbook.
Making our use cases configurable.
Our label is now ready for use in the main app, but there’s one more thing I want to try before we get to that: allowing configuration of a widget in the widgetbook itself.
Widgetbook calls this feature “knobs”. As our InfoPanel
allows the user to specify two properties, the message and the level, I will need one knob each…
Except here’s where we run into a bit of an issue … there’s (as of the time of this writing), no knob for enums. There is a way to add your own custom knobs, but the documentation notes that it’s impossible to add your own field to it, so the user would have to know the enum values anyway.
I’ll console myself with keeping the three use cases I have but allowing the user to specify a new message.
@UseCase(name: "info panel", type: InfoPanel)
Widget infoPanel(BuildContext context) => Center(
child: InfoPanel(
level: MessageLevel.info,
message: context.knobs.string(
label: "message",
initialValue: "This is an info message.",
),
),
);
@UseCase(name: "warning panel", type: InfoPanel)
Widget warningPanel(BuildContext context) => Center(
child: InfoPanel(
level: MessageLevel.warning,
message: context.knobs.string(
label: "message",
initialValue: "This is a warning message.",
),
),
);
@UseCase(name: "error panel", type: InfoPanel)
Widget errorPanel(BuildContext context) => Center(
child: InfoPanel(
level: MessageLevel.error,
message: context.knobs.string(
label: "message",
initialValue: "This is an error message.",
),
),
);
which in the end turns out like this:
Displaying our widget in the main app
Now that we’re happy with how things look in our widgetbook, let’s get to actually using the widgets created therein in our main app.
The demo app created for us when we set up the project is a counter for how many times we pressed a button.
I will replace the count with an InfoPanel
widget with the following levels:
- error, if the count is
0
- warning, if the count is a multiple of
10
- info, otherwise
Importing our widgets package
First, of course, we need the widgets. Let’s add them in our pubspec.yaml
file:
dependencies:
flutter:
sdk: flutter
widgets:
path: 'widgets/'
Now run flutter pub get
and that’s it.
Have a look at the changes in your pubspec.lock
file and confirm that you didn’t accidentally pull any widgetbook dependencies.
Displaying our widget
Now that the application knows about the widgets, we can import them into our classes. In the main.dart
file, add
import 'package:widgets/text/infopanel.dart';
import 'package:widgets/text/messagelevel.dart';
InfoPanel
is now available for use. Somewhere in the current build()
function, we’ll find
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
This we’ll replace with
children: [
const InfoPanel(
level: MessageLevel.error,
message: "0",
),
],
Controlling the widget
A bit further down, we find
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
before, _incrementCounter
simply set a counter
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
}
and the setState()
call triggered a rebuild, which then read the _counter
from the state to pass a new label down into a Text
widget.
Flutter likes to pull state up into the parent.
We could do the same thing with the InfoPanel
, in which case it wouldn’t need to be a StatefulWidget
at all.
However, pretend for a moment that the InfoPanel
were more complex. For example, it could display not just the current message, but the previous messages, too. Maybe in some kind of slowly fading log of messages.
Internally, such a FadingInfoPanel
would keep track of the previously displayed messages, and add the newest message on top of them all. That state should not be exposed to the outside world because displaying it is the widget’s task and nobody else needs know about this.
Conceptually, we would want to call setState
on a child widget.
I ran that idea past one of our flutter engineers, and they told me that to achieve that kind of thing, I want to look into “controllers”.
Look into the TextEditingController
, if you want a more sophisticated example, but the idea is simple:
A controller is a an observable that the state object is listening to.
Once the controller notifies a change, the state object uses
setState
to update itself according to its own logic.
So for our purposes, it could be as simple as
class InfoPanelController extends ChangeNotifier {
late MessageLevel _level;
late String _message;
void showMessage({required MessageLevel level, required String message}) {
_level = level;
_message = message;
super.notifyListeners();
}
}
(added in our widgets/
library, of course).
We will add an optional controller
parameter to our widget constructor
class InfoPanel extends StatefulWidget {
final InfoPanelController? controller;
final MessageLevel level;
final String message;
const InfoPanel({
super.key,
this.controller,
this.level = MessageLevel.info,
this.message = '<placeholder message>',
});
@override
State<InfoPanel> createState() => _InfoPanelState();
}
the state can now retrieve that controller
on initState
and listen to it:
class _InfoPanelState extends State<InfoPanel> {
late InfoPanelController _controller;
late MessageLevel _level;
late String _message;
@override
void initState() {
assert(widget.message.isNotEmpty, 'InfoPanel message must not be empty.');
_level = widget.level;
_message = widget.message;
_controller = widget.controller ?? InfoPanelController();
_controller.addListener(() {
setState(() {
_level = _controller._level;
_message = _controller._message;
});
});
super.initState();
}
// ...
}
and that’s already it.
In our main app, we can now instantiate an InfoPanelController
and pass it on to the InfoPanel
widget:
class _MyHomePageState extends State<MyHomePage> {
final _infoController = InfoPanelController();
int _counter = 0;
void _incrementCounter() {
_counter++;
_infoController.showMessage(
level: switch (_counter % 10) {
0 => MessageLevel.warning,
_ => MessageLevel.info,
},
message: '$_counter',
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InfoPanel(
controller: _infoController,
level: MessageLevel.error,
message: "0",
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
And voila:
Review
A lot of stuff happened here. To summarise:
- https://pub.dev/ is where you will find most of the libraries you would want to use, but you can also declare dependencies on git repositories or local packages
- the
pubspec.yaml
file controls your dependencies, you use flutter pub to modify it pubspec.lock
is generated and controls which exact versions are used when building the app, it also keeps track of transitive dependencies and such- for applications, you should check in
pubspec.lock
, for other packages you shouldn’t dependencies:
are required by clients of a package,dev_dependencies:
are only required to develop the package itself.- we created a widget library using widgetbook
- to do so, first create a flutter package
flutter create --template=package widgets
- which will create a
widgets/
folder with apubspec.yaml
file, - edit
widgets/pubspec.yaml
to change name, description, etc. - add a dev dependency on widgetbook to the
widgets/
package
flutter pub add dev:widgetbook_annotation dev:widgetbook dev:widgetbook_generator dev:build_runner
- stuff placed in
lib
gets exported to clients of the package, stuff inlib/src
stays private to the package - create a widgetbook app in
widgets/lib/src
import 'package:flutter/material.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
import 'widgetbook_app.directories.g.dart' show directories;
void main() {
runApp(const WidgetbookApp());
}
@widgetbook.App()
class WidgetbookApp extends StatelessWidget {
const WidgetbookApp({super.key});
@override
Widget build(BuildContext context) {
return Widgetbook.material(
// Use the generated directories variable
directories: directories,
addons: const [],
integrations: const [],
);
}
}
- run
flutter pub run build_runner build
to build the widgetbook and generate thewidgetbook_app.directories.g.dart
file - use
watch
instead ofbuild
to automatically rebuild when there are changes to the package - make sure to add
widgetbook_app.directories.g.dart
to the.gitignore
- add your widgets directly in
widgets/lib
- for
StatefulWidgets
, also create their state objects in the same file - if your widget needs a controller, also add it here. a controller is an observable that your state listens to to update itself.
- add your use cases to be displayed in the widgetbook under
widgets/lib/src
@UseCase(name: "info panel", type: InfoPanel)
Widget infoPanel(BuildContext context) => Center(
child: InfoPanel(
level: MessageLevel.info,
message: context.knobs.string(
label: "message",
initialValue: "This is an info message.",
),
),
);
- widgetbook groups your use cases in the navigation by folder and by type of the widget you declare in the
@UseCase
annotation. - you can run the widgetbook app in a browser
- add a dependency on the
widgets/
package in the main application’spubspec.yaml
file
dependencies:
flutter:
sdk: flutter
widgets:
path: 'widgets/'
- import widgets from your
widgets/
library using thepackage:
prefix, e.g.
import 'package:widgets/text/infopanel.dart';
import 'package:widgets/text/messagelevel.dart';