Skip to content

feat: add InteractiveViewerAddon#1784

Open
EArminjon wants to merge 3 commits intowidgetbook:mainfrom
EArminjon:feat/rework-zoom-addon
Open

feat: add InteractiveViewerAddon#1784
EArminjon wants to merge 3 commits intowidgetbook:mainfrom
EArminjon:feat/rework-zoom-addon

Conversation

@EArminjon
Copy link
Copy Markdown
Contributor

@EArminjon EArminjon commented Dec 17, 2025

This PR rework ZoomAddon to use an InteractiveViewer instead of a Transform.scale() to let user zoom where he want instead of only the middle.

List of issues which are fixed by the PR

Screenshots

Enregistrement.de.l.ecran.2025-12-17.a.09.48.35.mov

Checklist

  • I signed the CLA.
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making].
  • All existing and new tests are passing.

If you need help, consider asking for advice on Discord.

@vercel
Copy link
Copy Markdown

vercel bot commented Dec 17, 2025

@EArminjon is attempting to deploy a commit to the Widgetbook Team on Vercel.

A member of the Team first needs to authorize it.

@docs-page
Copy link
Copy Markdown

docs-page bot commented Dec 17, 2025

To view this pull requests documentation preview, visit the following URL:

docs.page/widgetbook/widgetbook~1784

Documentation is deployed and generated using docs.page.

@EArminjon EArminjon force-pushed the feat/rework-zoom-addon branch 3 times, most recently from 34a337b to 092bf7f Compare December 17, 2025 09:20
Comment on lines +15 to +16
this.minScale = 1,
this.maxScale = 10,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot introduce breaking changes to the constructor. By removing initialZoom, when users upgrade, their Widgetbook will stop working. Instead, we should deprecate initialZoom and introduce the new params alongside it. You can have a look at TextScaleAddon where we had a similar change.

///
/// Learn more: https://docs.widgetbook.io/addons/zoom-addon
class ZoomAddon extends WidgetbookAddon<double> {
class ZoomAddon extends WidgetbookAddon<bool> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By changing this addon to bool addon, then we lose the concept of "configuring" this addon on Widgetbook Cloud.

Imagine if users are testing are using a ZoomAddonConfig with a value of 2. This means that they expect their screenshot to be zoomed/scaled 2x.

With this change here, the addon becomes less configurable and turns into a "dev" addon (similar to inspector addon), where you turn it on or off only during development to help you build better widgets.

So we have two options here:

  1. Debate if ZoomAddonConfig was of any use in the first place, and deprecate it with this change, as we cannot also introduce breaking changes (double -> bool) there. (Open for discussions)
  2. Instead of editing this addon, we can introduce a new PanAddon or InteractiveZoomAddon and keep the old addon as-is.

This problem can be avoided if you found a way to make InteractiveViewer to zoom to certain factor from the center. Then we can revert changes to ZoomAddonConfig.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR updated to avoid impacting ZoomAddon.

I strongly suggest reconsidering the relevance of ZoomAddon, as it only zooms the center of a use case and provides limited value.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that we need to reconsider the ZoomAddon. I see that there are no Widgetbook Cloud users that use the ZoomAddonConfig, so we can change the addon to be a "dev addon" as follows, to avoid addons clutter:

  1. Deprecate ZoomAddonConfig.
  2. Move InteractiveZoomAddon back to ZoomAddon and update it accordingly without any constructor breaking changes. I would argue that we don't even need the min/max scales as well. We can hardcode it to value that make sense to simplify the API.
  3. Deprecate initialZoom from the ZoomAddon constructor as well.

Comment thread packages/widgetbook/lib/src/addons/zoom_addon/zoom_addon.dart Outdated
@EArminjon EArminjon force-pushed the feat/rework-zoom-addon branch 5 times, most recently from 8e81b14 to a3ba1ab Compare December 19, 2025 13:09
@EArminjon EArminjon requested a review from yousinix December 19, 2025 13:22
@EArminjon
Copy link
Copy Markdown
Contributor Author

@yousinix

I would like to go deeper inside the interactive viewer addon. Actually, when a dev 'hot reload' then scale and X/Y are reset.
The code below works but that maybe not the best way. I worry about the manual FieldCodec.decodeQueryGroup and updateQueryField. What do you think ?

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:widgetbook/widgetbook.dart';

/// A [WidgetbookAddon] for zoom/scaling the widget tree.
///
/// The zoom addon allows developers to scale the entire widget tree
/// to test how components behave at different zoom levels.
///
/// Learn more: https://docs.widgetbook.io/addons/zoom-addon
class CustomZoomAddon extends WidgetbookAddon<bool> {
  /// Creates a new instance of [ZoomAddon].
  CustomZoomAddon({this.minScale = 1, this.maxScale = 30}) : super(name: 'Zoom');

  final double minScale;
  final double maxScale;

  @override
  List<Field> get fields => [BooleanField(name: 'enabled')];

  @override
  bool valueFromQueryGroup(Map<String, String> group) {
    return valueOf('enabled', group) as bool;
  }

  @override
  Widget buildUseCase(BuildContext context, Widget child, bool setting) {
    if (!setting) return child;
    return InteractiveViewerWidget(minScale: minScale, maxScale: maxScale, child: child);
  }
}

class InteractiveViewerWidget extends StatefulWidget {
  const InteractiveViewerWidget({required this.minScale, required this.maxScale, required this.child, super.key});

  final double minScale;
  final double maxScale;
  final Widget child;

  @override
  State<InteractiveViewerWidget> createState() => _InteractiveViewerWidgetState();
}

class _InteractiveViewerWidgetState extends State<InteractiveViewerWidget> {
  TransformationController? _controller;
  Timer? _debounceTimer;

  @override
  void initState() {
    super.initState();

    WidgetsBinding.instance.addPostFrameCallback((_) {
      final screenSize = MediaQuery.sizeOf(context);
      final state = WidgetbookState.of(context);
      final groupMap = FieldCodec.decodeQueryGroup(state.queryParams['zoom']);

      /// Parse scale with default of 1
      final scale = double.tryParse(groupMap['scale'] ?? '1') ?? 1;

      /// Parse x and y with fallback to center
      final fallbackX = (screenSize.width / 2) * (1 - scale);
      final fallbackY = (screenSize.height / 2) * (1 - scale);
      final x = double.tryParse(groupMap['x'] ?? '') ?? fallbackX;
      final y = double.tryParse(groupMap['y'] ?? '') ?? fallbackY;

      _controller = TransformationController(Matrix4.identity()..scale(scale));
      _controller!.value.setTranslationRaw(x, y, 0);
      _controller!.addListener(_onTransformChanged);
      setState(() {});
    });
  }

  void _onTransformChanged() {
    final scale = _controller!.value.getMaxScaleOnAxis().toStringAsFixed(2);
    final x = _controller!.value.getTranslation().x.toStringAsFixed(2);
    final y = _controller!.value.getTranslation().y.toStringAsFixed(2);

    _debounceTimer?.cancel();
    _debounceTimer = Timer(const Duration(milliseconds: 100), () {
      WidgetbookState.of(context)
        ..updateQueryField(group: 'zoom', field: 'scale', value: scale)
        ..updateQueryField(group: 'zoom', field: 'x', value: x)
        ..updateQueryField(group: 'zoom', field: 'y', value: y);
    });
  }

  @override
  void dispose() {
    _controller?.removeListener(_onTransformChanged);
    _controller?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return InteractiveViewer(
      transformationController: _controller,
      minScale: widget.minScale,
      maxScale: widget.maxScale,
      child: widget.child,
    );
  }
}

@yousinix yousinix changed the title feat: Rework zoom addon feat: add InteractiveViewerAddon Jan 21, 2026
Copy link
Copy Markdown
Contributor

@yousinix yousinix left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @EArminjon! 👋
Apology for the very late response, we have been busy and on multiple vacations.


First of all, we should settle on ZoomAddon vs InteractiveViewerAddon. After doing research of the ZoomAddon usage, I see it's not so useful in its current state, so we can go back to your initial proposal of making this as the new ZoomAddon. This comes with:

  1. Removing any breaking changes to the constructor.
  2. Deprecating the ZoomAddonConfig as it's no longer needed.

I also like the idea of making this addon persist it's state across hot reloads. It can be beneficial. Here are some ideas that can make the code more concrete:

  1. We can change the addon from using bool to use ZoomSetting as a type, this will help restore the state from the group easier.
  2. Making the InteractiveViewerWidget unaware of Widgetbook by passing any callbacks or data from outside.
  3. Instead of calling updateQueryField 3 times, we can call updateQueryParam once, to reduce rebuilds.

Here's a high level code that you can use as a starter. Feel free to do any renames or type changes based on your needs.

@internal
class ZoomSetting {
  const ZoomSetting({
    required this.enabled,
    required this.scale,
    required this.x,
    required this.y,
  });

  final bool enabled;
  final double scale;
  final double x;
  final double y;
}

class ZoomAddon extends WidgetbookAddon<ZoomSetting> {
  /// Creates a new instance of [ZoomAddon].
  ZoomAddon() : super(name: 'Zoom');

  @override
  List<Field> get fields => [BooleanField(name: 'enabled')];

  @override
  ZoomSetting valueFromQueryGroup(Map<String, String> group) {
    final enabled = valueOf('enabled', group) as bool;

    return ZoomSetting(
      enabled: enabled,
      scale: double.tryParse(group['scale'] ?? '') ?? 1,
      x: double.tryParse(group['x'] ?? '') ?? 0,
      y: double.tryParse(group['y'] ?? '') ?? 0,
    );
  }

  @override
  Widget buildUseCase(BuildContext context, Widget child, ZoomSetting setting) {
    if (!setting.enabled) return child;
    return _RestorableInteractiveWidget(
      minScale: 1, // Fixed for now
      maxScale: 30, // Fixed for now
      translation: Point(setting.x, setting.y),
      scale: setting.scale,
      child: child,
      onTransformChanged: (translation, scale) {
        final newGroup = {
          'enabled': setting.enabled.toString(),
          'scale': scale.toStringAsFixed(2),
          'x': translation.x.toStringAsFixed(2),
          'y': translation.y.toStringAsFixed(2),
        };

        final encodedNewGroup = FieldCodec.encodeQueryGroup(newGroup);

        WidgetbookState.of(context).updateQueryParam('zoom', encodedNewGroup);
      },
    );
  }
}

// TODO: Extract this widget to separate file
class _RestorableInteractiveWidget extends StatefulWidget {
  const _RestorableInteractiveWidget({
    required this.minScale,
    required this.maxScale,
    required this.scale,
    required this.translation,
    required this.onTransformChanged,
    required this.child,
  });

  final double minScale;
  final double maxScale;
  final double scale;
  final Point translation;
  final void Function(Point translation, double scale) onTransformChanged;
  final Widget child;

  @override
  State<_RestorableInteractiveWidget> createState() =>
      _RestorableInteractiveWidgetState();
}

class _RestorableInteractiveWidgetState
    extends State<_RestorableInteractiveWidget> {
  TransformationController? _controller;
  Timer? _debounceTimer;

  @override
  void initState() {
    super.initState();

    WidgetsBinding.instance.addPostFrameCallback((_) {
      // TODO:
      // Instead of using `WidgetbookState.of(context)` here,
      // use widget.translation and widget.scale to set the initial transformation
      setState(() {});
    });
  }

  void _onTransformChanged() {
    final x = _controller!.value.getTranslation().x;
    final y = _controller!.value.getTranslation().y;
    final scale = _controller!.value.getMaxScaleOnAxis();

    _debounceTimer?.cancel();
    _debounceTimer = Timer(
      const Duration(milliseconds: 100),
      () => widget.onTransformChanged(Point(x, y), scale),
    );
  }

  @override
  void dispose() {
    _controller?.removeListener(_onTransformChanged);
    _controller?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return InteractiveViewer(
      transformationController: _controller,
      minScale: widget.minScale,
      maxScale: widget.maxScale,
      child: widget.child,
    );
  }
}

@EArminjon EArminjon force-pushed the feat/rework-zoom-addon branch from a3ba1ab to ce90171 Compare January 26, 2026 09:51
@EArminjon
Copy link
Copy Markdown
Contributor Author

Hello @EArminjon! 👋 Apology for the very late response, we have been busy and on multiple vacations.

First of all, we should settle on ZoomAddon vs InteractiveViewerAddon. After doing research of the ZoomAddon usage, I see it's not so useful in its current state, so we can go back to your initial proposal of making this as the new ZoomAddon. This comes with:

  1. Removing any breaking changes to the constructor.
  2. Deprecating the ZoomAddonConfig as it's no longer needed.

I also like the idea of making this addon persist it's state across hot reloads. It can be beneficial. Here are some ideas that can make the code more concrete:

  1. We can change the addon from using bool to use ZoomSetting as a type, this will help restore the state from the group easier.
  2. Making the InteractiveViewerWidget unaware of Widgetbook by passing any callbacks or data from outside.
  3. Instead of calling updateQueryField 3 times, we can call updateQueryParam once, to reduce rebuilds.

Here's a high level code that you can use as a starter. Feel free to do any renames or type changes based on your needs.

@internal
class ZoomSetting {
  const ZoomSetting({
    required this.enabled,
    required this.scale,
    required this.x,
    required this.y,
  });

  final bool enabled;
  final double scale;
  final double x;
  final double y;
}

class ZoomAddon extends WidgetbookAddon<ZoomSetting> {
  /// Creates a new instance of [ZoomAddon].
  ZoomAddon() : super(name: 'Zoom');

  @override
  List<Field> get fields => [BooleanField(name: 'enabled')];

  @override
  ZoomSetting valueFromQueryGroup(Map<String, String> group) {
    final enabled = valueOf('enabled', group) as bool;

    return ZoomSetting(
      enabled: enabled,
      scale: double.tryParse(group['scale'] ?? '') ?? 1,
      x: double.tryParse(group['x'] ?? '') ?? 0,
      y: double.tryParse(group['y'] ?? '') ?? 0,
    );
  }

  @override
  Widget buildUseCase(BuildContext context, Widget child, ZoomSetting setting) {
    if (!setting.enabled) return child;
    return _RestorableInteractiveWidget(
      minScale: 1, // Fixed for now
      maxScale: 30, // Fixed for now
      translation: Point(setting.x, setting.y),
      scale: setting.scale,
      child: child,
      onTransformChanged: (translation, scale) {
        final newGroup = {
          'enabled': setting.enabled.toString(),
          'scale': scale.toStringAsFixed(2),
          'x': translation.x.toStringAsFixed(2),
          'y': translation.y.toStringAsFixed(2),
        };

        final encodedNewGroup = FieldCodec.encodeQueryGroup(newGroup);

        WidgetbookState.of(context).updateQueryParam('zoom', encodedNewGroup);
      },
    );
  }
}

// TODO: Extract this widget to separate file
class _RestorableInteractiveWidget extends StatefulWidget {
  const _RestorableInteractiveWidget({
    required this.minScale,
    required this.maxScale,
    required this.scale,
    required this.translation,
    required this.onTransformChanged,
    required this.child,
  });

  final double minScale;
  final double maxScale;
  final double scale;
  final Point translation;
  final void Function(Point translation, double scale) onTransformChanged;
  final Widget child;

  @override
  State<_RestorableInteractiveWidget> createState() =>
      _RestorableInteractiveWidgetState();
}

class _RestorableInteractiveWidgetState
    extends State<_RestorableInteractiveWidget> {
  TransformationController? _controller;
  Timer? _debounceTimer;

  @override
  void initState() {
    super.initState();

    WidgetsBinding.instance.addPostFrameCallback((_) {
      // TODO:
      // Instead of using `WidgetbookState.of(context)` here,
      // use widget.translation and widget.scale to set the initial transformation
      setState(() {});
    });
  }

  void _onTransformChanged() {
    final x = _controller!.value.getTranslation().x;
    final y = _controller!.value.getTranslation().y;
    final scale = _controller!.value.getMaxScaleOnAxis();

    _debounceTimer?.cancel();
    _debounceTimer = Timer(
      const Duration(milliseconds: 100),
      () => widget.onTransformChanged(Point(x, y), scale),
    );
  }

  @override
  void dispose() {
    _controller?.removeListener(_onTransformChanged);
    _controller?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return InteractiveViewer(
      transformationController: _controller,
      minScale: widget.minScale,
      maxScale: widget.maxScale,
      child: widget.child,
    );
  }
}

Hi,

I've reworked the InteractiveViewerAddon, tell me if something is wrong. I didn't touched ZoomAddon, i let you keep one if you prefer.

@EArminjon EArminjon force-pushed the feat/rework-zoom-addon branch 3 times, most recently from 199b025 to f3bcfc8 Compare January 26, 2026 10:05
@EArminjon EArminjon force-pushed the feat/rework-zoom-addon branch from f3bcfc8 to 8b74ce0 Compare January 26, 2026 10:09
@EArminjon
Copy link
Copy Markdown
Contributor Author

EArminjon commented Jan 26, 2026

(Unit test and CI test fixed)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants