Setting up a Flutter Widget Library

Part 5 in my “Flutter from a complete beginner” series.

Tiger Asks...
13 min readFeb 5, 2024

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:

Fig.3: the `widget` directory is new

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 labour
  • dev:widgetbook_generator then takes these annotations and generates the code that we would have to add ourselves
  • dev: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:

Fig.4: run configuration for watching the widgetbook

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:

Fig.5: run configuration for our widgetbook

And we’ll run that in a browser.

Fig.6 running the widgetbook app

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:

Fig.7: using material design

The app displays without issues.

Fig.8: running, but empty widget book

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 a StatelessWidget .

If you type stful, it will offer the boilerplate for a StatefulWidget.

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.

Fig.9: how we want our widgets to look, in the end

(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:

  • Text — to display the message
  • Container — to add some styling around the message

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:

Fig.10: which import is the correct one?

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 the InfoPanel 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 are final, any modifications must go through the state object
  • the initial state set in the widget’s constructor are read from State.widget in the State.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 uses
  • MyWidget is what I’ll call the “group type”. All MyWidget 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.

Fig.11: where and how to add use cases

Let’s run the widgetbook application. (If you chose not to watch your widgets earlier, remember to build the widgetbook, first.)

Fig.12: how our use cases get displayed in the widgetbook

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 from BuildContext with Theme.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

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.

Fig.13: a properly formatted error panel

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:

Fig.14: our info label is now configurable

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.

Fig.15: the auto-generated flutter demo app

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:

Fig.16: our widget updates correctly

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 a pubspec.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 in lib/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 the widgetbook_app.directories.g.dart file
  • use watch instead of build 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’s pubspec.yaml file
dependencies:
flutter:
sdk: flutter

widgets:
path: 'widgets/'
  • import widgets from your widgets/ library using the package: prefix, e.g.
import 'package:widgets/text/infopanel.dart';
import 'package:widgets/text/messagelevel.dart';

--

--

Tiger Asks...

🇨🇭-based Software Engineer with a lot of questions and some answers.