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

  1. Create a new Flutter project inside your app's directory:

    flutter create widgetbook_workspace --empty --platforms web,macos
    
  2. Rename widgetbook_workspace directory to widgetbook:

    mv widgetbook_workspace widgetbook
    
  3. Add the following dependencies to your widgetbook project:

    flutter pub add widgetbook dev:widgetbook_generator dev:build_runner
    
  4. Opt-in to the new widgetbook_generator by creating the file widgetbook/build.yaml, and adding the following content:

    targets:
      $default:
        builders:
          widgetbook_generator:experimental_story_builder:
            enabled: true
          widgetbook_generator:experimental_components_builder:
            enabled: true
    
  5. 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

  1. 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>();
    
  2. Run dart run build_runner build -d inside the widgetbook directory to generate the my_cool_widget.stories.book.dart file, which contains some generated classes to help you catalog your widget.

  3. 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.
  4. Run dart run build_runner build -d again to add the new story to the generated MyCoolWidgetComponent.stories list.

  5. 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:

TypeKnobArg
boolbooleanBoolArg
intint.inputIntArg
intint.slider-
doubledouble.inputDoubleArg
doubledouble.slider-
StringstringStringArg
DurationdurationDurationArg
DateTimedateTimeWIP
ColorcolorColorArg
TlistSingleArg<T>
EnumlistEnumArg<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.

PrimitiveNullableRequiredDefaultArg
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

  1. 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',
              ),
            ),
          ],
        ),
      );
    }
    
  2. 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

  1. Go to Configure User Snippets under Code > Settings

  2. Choose Dart.

  3. 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

  1. Setting > Editor > Live Templates > "+" > Live Template

  2. Abbreviation: stry

  3. Description: Creates a new Widgetbook Story

  4. 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',
    );
    
  5. Click on "Edit Variables" and add the following:

    NameExpressionSkip if defined
    FILEfileNameWithoutExtension()
    WIDGETvariableOfType("Widget")
  6. Click "Apply" and "OK".