Widgetbook 4: SAM Architecture
The Story-Arg-Mode architecture or "SAM" (inspired by Samwise Gamgee) for short, will become the new default with the upcoming Widgetbook 4.
The SAM architecture is still in early development phase. Only use if you like to test bleeding-edge features.
Project Setup
-
Create a new Flutter project inside your app's directory:
flutter create widgetbook_workspace --empty --platforms web,macos
-
Rename
widgetbook_workspace
directory towidgetbook
:mv widgetbook_workspace widgetbook
-
Add the following dependencies to your
widgetbook
project:flutter pub add widgetbook dev:widgetbook_generator dev:build_runner
-
Opt-in to the new
widgetbook_generator
by creating the filewidgetbook/build.yaml
, and adding the following content:targets: $default: builders: widgetbook_generator:experimental_story_builder: enabled: true widgetbook_generator:experimental_components_builder: enabled: true
-
Create the cyclic dependency between your app and
widgetbook
:
Although it hurts developers to see the c-word used, but this case is completely fine. The cyclic dependency is only used to generate code and will not be part of the final app.# widgetbook/pubspec.yaml ⇒ for code generation dependencies: <your_app_name>: path: ../
# pubspec.yaml (app) ⇒ for golden tests dev_dependencies: widgetbook_workspace: path: widgetbook
Cataloging Widgets
-
Create a new file
widgetbook/lib/my_cool_widget.stories.dart
:import 'package:flutter/material.dart'; import 'package:widgetbook/next.dart'; import 'package:<your_app_name>/path/to/my_cool_widget.dart'; part 'my_cool_widget.stories.book.dart'; final meta = Meta<MyCoolWidget>();
-
Run
dart run build_runner build -d
inside thewidgetbook
directory to generate themy_cool_widget.stories.book.dart
file, which contains some generated classes to help you catalog your widget. -
Add a new story to the
my_cool_widget.stories.dart
file:final $Primary = MyCoolWidgetStory( name: 'Default', );
The$
prefix is used to indicate that the variable is a story. -
Run
dart run build_runner build -d
again to add the new story to the generatedMyCoolWidgetComponent.stories
list. -
Add the following code to your
widgetbook/lib/main.dart
file:import 'package:flutter/material.dart'; import 'package:widgetbook/next.dart' as next; import 'package:widgetbook/widgetbook.dart'; import 'components.book.dart'; void main() { runApp(const WidgetbookApp()); } class WidgetbookApp extends StatelessWidget { const WidgetbookApp({super.key}); @override Widget build(BuildContext context) { return Widgetbook.material( // If you need backwards compatibility with v3: // directories: [ // ...components, // ...directories, // ], directories: components, addons: [ next.MaterialThemeAddon({ 'Dark': ThemeData.dark(), 'Light': ThemeData.light(), }), ], ); } }
Args
Args are the replacement of knobs from Widgetbook 3. They can be thought of as "typed" knobs. They are also used for golden testing later on. They are generated based on the default constructor of your widget you are trying to catalog.
For example, if you tried cataloging the following widget:
class CoolButton extends StatelessWidget {
const CoolButton({
super.key,
required this.text,
required this.color,
});
final String text;
final Color color;
@override
Widget build(BuildContext context) { ... }
}
Then the generated CoolButtonStory
class can accept CoolButtonArgs
as follows:
final meta = Meta<CoolButton>();
final $Primary = CoolButtonStory(
name: 'Primary',
args: CoolButtonArgs(
text: StringArg('Hello World'),
color: ColorArg(Colors.blue),
),
);
final $Default = CoolButtonStory(
name: 'Default',
args: CoolButtonArgs(
text: Arg.fixed('constant text'), // Constant Arg (no knob)
// color: uses the default value from the constructor
),
);
Args vs Knobs
If you were using Widgetbook 3 knobs, here is a mapping of the knobs to the new args:
Type | Knob | Arg |
---|---|---|
bool | boolean | BoolArg |
int | int.input | IntArg |
int | int.slider | - |
double | double.input | DoubleArg |
double | double.slider | - |
String | string | StringArg |
Duration | duration | DurationArg |
DateTime | dateTime | WIP |
Color | color | ColorArg |
T | list | SingleArg<T> |
Enum | list | EnumArg<T> |
Param-Arg Resolution
If you are wondering what type of arg you will have generated based on your constructor parameter, here is a mapping:
This mapping will change once the nullable Args are implemented.
Primitive | Nullable | Required | Default | Arg |
---|---|---|---|---|
✅ | ✅ | ✅ | ❌ | Arg<Primitive>? = PrimitiveArg(primitive.default) |
✅ | ✅ | ❌ | ✅ | Arg<Primitive>? = PrimitiveArg(param.default) |
✅ | ✅ | ❌ | ❌ | Arg<Primitive>? = PrimitiveArg(primitive.default) |
✅ | ❌ | ✅ | ❌ | Arg<Primitive> = PrimitiveArg(primitive.default) |
✅ | ❌ | ❌ | ✅ | Arg<Primitive> = PrimitiveArg(param.default) |
❌ | ✅ | ✅ | ❌ | Arg<Type>? |
❌ | ✅ | ❌ | ✅ | Arg<Type>? = Arg.fixed(param.default) |
❌ | ✅ | ❌ | ❌ | Arg<Type>? |
❌ | ❌ | ✅ | ❌ | required Arg<Type> |
❌ | ❌ | ❌ | ✅ | Arg<Type> = Arg.fixed(param.default) |
Custom Args
Args usually match the Widget's constructor, but in some cases users want to customize that.
// 1. Define the custom args class
class CustomInputs {
CustomInputs({
required this.number,
});
final int number;
}
// 2. Use `MetaWithArgs` instead of `Meta`
final meta = MetaWithArgs<CoolWidget, CustomInputs>();
final $Default = CoolWidgetStory(
name: 'Default',
// 3. Specify how to convert the args (CustomInputsArgs) to a widget (CoolWidget)
argsBuilder: (context, args) => CoolWidget(
text: args.number.resolve(context).toString(),
),
);
Modes
In Widgetbook 4, a new building block is added, called "Mode". You can think of Modes as a global state that customizes your widget. And addon is mostly used to toggle between these modes.
For instance, there is a ThemeMode
that can be used to wrap any story to show it with a certain theme variables. The ThemeAddon
can be used to toggle between ThemeMode(dark)
& ThemeMode(light)
.
Modes are introduces to make golden testing easier with Widgetbook 4.
Golden Testing
-
To create your first golden test with Widgetbook 4, add a new file to your app's
test
directory:We are using
alchemist
as a golden testing library, but the solution is package-agnostic, and any other package can be used.import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:widgetbook/next.dart'; import 'package:<your_app_name>/path/to/my_cool_widget.dart'; import 'package:widgetbook_workspace/my_cool_widget.stories.dart'; void main() { goldenTest( MyCoolWidgetComponent.name, fileName: MyCoolWidgetComponent.name, builder: () => GoldenTestGroup( children: [ MyCoolWidgetScenario( name: 'Primary', story: $Primary, modes: [ThemeMode(ThemeData.dark())] args: MyCoolWidgetArgs.fixed( foo: 'bar', ), ), ], ), ); }
-
Run
flutter test --update-goldens
.
Snippets
To make the process of creating new stories easier, you can add the following snippet to your IDE / Text Editor.
Visual Studio Code
-
Go to Configure User Snippets under Code > Settings
-
Choose Dart.
-
Add the following:
{ "Widgetbook Story": { "prefix": "stry", "description": "Creates a new Widgetbook Story", "body": [ "import 'package:flutter/widgets.dart';", "import 'package:widgetbook/next.dart';", "", "part '$TM_FILENAME_BASE.book.dart';", "", "", "final meta = Meta<${1:Widget}>();", "", "final $Default = ${1:Widget}Story(", " name: 'Default',", ");" ] } }
JetBrains IDEs
-
Setting > Editor > Live Templates > "+" > Live Template
-
Abbreviation:
stry
-
Description:
Creates a new Widgetbook Story
-
Template Text:
import 'package:flutter/widgets.dart'; import 'package:widgetbook/next.dart'; part '$FILE$.book.dart'; final meta = Meta<$WIDGET$>(); final $Default = $WIDGET$Story( name: 'Default', );
-
Click on "Edit Variables" and add the following:
Name Expression Skip if defined FILE
fileNameWithoutExtension()
✅ WIDGET
variableOfType("Widget")
-
Click "Apply" and "OK".