Я разрабатываю приложения на Flutter уже много лет, и то, как он развивается, не перестает меня удивлять. Сегодня Flutter Widget Previewer — это уже не эксперимент, а нормальный инструмент для проектирования UI: он позволяет видеть, как виджеты отрисовываются в реальном времени, без запуска приложения целиком.

Если вы когда-либо пользовались канвасом SwiftUI или предпросмотрами в Jetpack Compose, этот подход покажется вам знакомым.

Когда я впервые попробовал Widget Previewer, сразу стало ясно: дело не просто в более быстром Hot Reload, а в изоляции UI, почти как в Storybook для Flutter. Вместо того чтобы переходить по приложению в поисках нужного состояния, вы создаете предпросмотры, которые живут рядом с кодом, и каждая вариация — светлая и темная тема, разные размеры, состояния с ошибками — всегда доступна для проверки.

В этой статье разберем, что такое Flutter Widget Previewer, как им пользоваться и как этот инструмент помогает ускорить разработку интерфейсов. Заодно поговорим о нескольких особенностях, о которых стоит помнить.

Начнем.

Что такое Widget Previewer на самом деле

Widget Previewer — это инструмент, который изолирует Flutter-виджеты и отрисовывает их в реальном времени прямо во время написания кода, полностью отдельно от основного UI приложения.

Впервые он появился как экспериментальная возможность в Flutter 3.35 и призван заметно ускорить процесс создания и проверки UI. Чтобы понять его ценность, достаточно одной мысленной модели:

считайте, что это специализированный холст (canvas) для проектирования.

С помощью Previewer вы мгновенно видите виджет или сразу несколько его вариантов в отдельной области предпросмотра. Это избавляет от лишнего «шума»: больше не нужно запускать тяжеловесный эмулятор или пробираться через пять экранов приложения только ради того, чтобы проверить изменение в один пиксель. Вы сразу получаете живую отрисовку прямо рядом с кодом.

Почему это действительно важно

Подумайте о типичной «цене», которую мы платим при тестировании UI. Часто нужно проверить, как виджет выглядит на экранах разных размеров, с разным масштабом текста, в светлой и темной теме или на разных языках.

Раньше для этого приходилось снова и снова пересобирать и запускать приложение, переключаться между симуляторами устройств или вручную менять настройки для каждого отдельного сценария. Это было утомительно и постоянно выбивало из рабочего ритма.

Widget Previewer полностью меняет этот процесс. С помощью специальных аннотаций предпросмотра вы можете видеть все эти варианты рядом друг с другом, почти мгновенно и без перезапуска приложения целиком.

Виджет с аннотацией @Preview

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

@Preview(name: 'My Sample Text')
Widget mySampleText() {
  return const Text('Hello, World!');
}

Иными словами, Flutter наконец получает знакомый по SwiftUI быстрый цикл обратной связи, который позволяет быстрее вносить изменения в дизайн и делать это с куда большей уверенностью.

Без Widget Previewer

С Widget Previewer

1. Сохраните код

1. Сохраните код

2. Срабатывает Hot Reload (мгновенно)

2. Виджет сразу появляется в окне предпросмотра

3. Пройдите через экран заставки → экран входа → экран формы

3. Переключите светлую/темную тему

4. Включите темную тему в настройках

4. Сразу посмотрите, как поле ввода выглядит в обеих темах

5. Посмотрите на поле ввода

5. Измените цвет рамки

6. Вернитесь назад, измените цвет рамки

6. Наблюдайте, как изменения применяются в реальном времени

7. Сохраните код и повторяйте шаги 1–5

Почему одного Hot Reload недостаточно

Hot Reload отлично ускоряет внесение изменений в код, но по-прежнему предполагает, что для просмотра каждого состояния вы будете переходить по приложению вручную. Widget Previewer избавляет от этой «цены навигации», сохраняя все варианты видимыми рядом друг с другом: тему, размер, масштаб текста, состояния ошибки, подсказки и отключения. Кроме того, он позволяет избежать дублирования (DRY) в описании предпросмотров с помощью @MultiPreview, так что компоненты дизайн-системы остаются согласованными без ручных действий.

Включение Flutter Widget Previewer

Чтобы эффективно пользоваться Previewer, нужен Flutter 3.38 или новее. Технически он появился еще в 3.35, но интеграция со средой разработки, ради которой все это и нужно, появилась только в 3.38.

Запустить его можно тремя способами:

  • VS Code

  • Android Studio / IntelliJ

  • Командная строка (ручной режим)

VS Code

Previewer встраивается прямо в боковую панель IDE. Найдите вкладку «Flutter Widget Preview» в боковой панели, обычно справа. Нажмите на нее, чтобы запустить окружение. Дальше инструмент будет ждать ваши аннотации.

Android Studio / IntelliJ

Как и в VS Code, это отдельное окно инструментов. Найдите окно инструментов «Предварительный просмотр виджетов Flutter» ("Flutter Widget Preview"). Откройте его, чтобы инициализировать фоновый процесс.

Командная строка (ручной режим)

Если вы предпочитаете использовать терминал или хотите запустить программу в отдельном окне Chrome, выполните следующую команду

flutter widget-preview start

из корневой папки проекта. Она запустит локальный сервер и откроет окно предварительного просмотра в Chrome. При сохранении автоматически обновится.

Ограничение, связанное с «вебом»

Под капотом Previewer использует Dart Development Compiler (DDC), который генерирует JavaScript. Widget Previewer поддерживает предпросмотр виджетов, зависящих от dart:io и в будущем от dart:ffi, но покажет ошибку, если в действительности будут вызваны API из этих неподдерживаемых библиотек. То же самое касается виджетов, зависящих от плагинов Flutter без поддержки веба.

Как создать свой первый предпросмотр

Когда Previewer уже запущен, возникает вопрос: как именно указать ему, какие виджеты нужно показывать?

Для этого используется аннотация @Preview. Flutter предоставляет ее в пакете flutter/widget_previews.dart.

Когда вы помечаете виджет, функцию, создающую виджет, или конструктор аннотацией @Preview, вы регистрируете этот элемент как точку предпросмотра. Previewer сам обнаружит его и автоматически отрисует.

Вот базовые правила и шаги для создания предпросмотра:

  • Выберите виджет, который хотите показать в предпросмотре. @Preview можно использовать для:

    • Функции верхнего уровня, которая возвращает Widget или WidgetBuilder

    • Статического метода класса, который возвращает Widget

    • Публичного конструктора или фабрики Widget без обязательных параметров, чтобы его можно было вызвать без аргументов

  • Добавьте аннотацию @Preview прямо над этой функцией, методом или конструктором класса. При желании можно передать параметр name, чтобы задать понятное имя предпросмотра в UI. Иначе будет использовано имя по умолчанию.

Что происходит «под капотом»: как работает @Preview

Аннотация @Preview реализована как базовый класс в widget_previews.dart во Flutter. Ее сила в двойственной природе: она одновременно работает и как аннотация времени компиляции, и как объект конфигурации времени выполнения.

Когда вы пишете @Preview(), вы создаете константный экземпляр класса Preview. Инструменты Flutter анализируют ваш код во время компиляции, находят такие аннотации и регистрируют связанные с ними функции как цели для предпросмотра. Сама аннотация содержит все данные конфигурации — тему, размер, яркость и так далее, — которые нужны Previewer, чтобы корректно отрисовать ваш виджет.

Вот упрощенный вид его структуры:

base class Preview {
  const Preview({
    String group = 'Default',
    String? name,
    Size? size,
    double? textScaleFactor,
    WidgetWrapper? wrapper,
    PreviewTheme? theme,
    Brightness? brightness,
    PreviewLocalizations? localizations,
  });

  final String group;
  final String? name;
  final Size? size;
  final double? textScaleFactor;
  final WidgetWrapper? wrapper;
  final PreviewTheme? theme;
  final Brightness? brightness;
  final PreviewLocalizations? localizations;

  // Преобразует этот Preview в изменяемый builder
  PreviewBuilder toBuilder() => PreviewBuilder._fromPreview(this);

  // Точка расширения для изменений во время выполнения
  Preview transform() => this;
}

Вот в чем на самом деле состоит это «ограничение»: в Dart аргументы аннотаций, то есть все, что находится внутри @Preview(...), должны быть константами времени компиляции. Это означает, что параметры обратного вызова, такие как theme или wrapper, могут быть только константной ссылкой на функцию, то есть функцией верхнего уровня или статическим методом.

Нельзя передать встроенное замыкание (closure), например (child) => ..., или метод экземпляра вроде someObject.wrap, потому что они не являются константами времени компиляции.

Об этом также упоминается в документации к исходному коду в репозитории Flutter.

Как использовать?

Убедитесь, что вы импортировали пакет Previewer:

import 'package:flutter/widget_previews.dart';

Например, давайте создадим предпросмотр простого виджета Text, определив функцию верхнего уровня:

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

@Preview(name: 'My Sample Text')
Widget mySampleText() {
  return const Text('Hello, World!');
}

В этом примере мы создаем функцию mySampleText, которая возвращает виджет Text, и помечаем ее аннотацией @Preview, задавая имя «My Sample Text». Как только вы сохраните этот файл, Previewer его подхватит.

В панели предпросмотра или в браузере вы сразу увидите карточку или область, где «My Sample Text» будет отрисован как Hello, World!.

По умолчанию предпросмотры группируются по файлам: Previewer показывает предпросмотры из файла, который сейчас открыт у вас в IDE. При необходимости фильтрацию можно отключить, чтобы увидеть все предпросмотры по проекту.

Элементы управления Previewer

Теперь посмотрим внимательнее на элементы управления, доступные в карточке предпросмотра. Для любого виджета обычно доступны пять круглых кнопок с иконками:

  • Увеличение/уменьшение масштаба — позволяет приблизить или отдалить виджет. Это полезно, если виджет маленький или если нужно рассмотреть детали.

  • Сброс масштаба — возвращает масштаб по умолчанию.

  • Переключение светлой/темной темы — мгновенно переключает предпросмотр между светлой и темной темой. Это удобно для проверки оформления и контрастности.

  • Hot Restart для предпросмотра — перезапускает предпросмотр конкретного виджета. Это позволяет применить изменения вроде нового состояния или статических инициализаторов без перезапуска всего окружения предпросмотра.

  • В общем окне предпросмотра также есть кнопка глобального перезапуска, которая пересобирает всё целиком. Она полезна, если что-то пошло не так с состоянием.

Настройка предпросмотров

Аннотация @Preview не рассчитана на один-единственный сценарий. У нее есть несколько параметров, которые позволяют подстроить предпросмотр под ваши задачи.

Вот несколько самых полезных вариантов настройки:

  • name: метка для предпросмотра. Она отображается как заголовок над областью предпросмотра, например «My Sample Text» в предыдущем примере.

  • size: позволяет задать ограничения по ширине и высоте для виджета в предпросмотре с помощью объекта Size. Это очень удобно, если нужно имитировать разные размеры экрана или контейнера. Например, @Preview(size: Size(375, 812)) может показать, как виджет выглядит на холсте размером с iPhone. Если ваш виджет по своей природе растягивается на весь доступный родительский контейнер, фиксированный размер в предпросмотре не даст ему бесконечно расширяться.

@Preview(
  name: 'iPhone SE Sized',
  size: Size(375, 667) // 👈 Ограничивает размер холста
)
Widget mobileView() => MyResponsiveWidget();
  • brightness: задает начальную яркость темы в предпросмотре, светлую или темную. Можно использовать Brightness.light или Brightness.dark. Это избавляет от необходимости каждый раз переключать тему вручную, если вам нужен конкретный вариант по умолчанию.

  • theme: сюда можно передать собственный PreviewThemeData через функцию, чтобы задать для предпросмотра полные настройки темы Material или Cupertino. Это уже более продвинутый вариант использования. По сути, вы можете заставить предпросмотр использовать ThemeData вашего приложения, если передадите функцию, возвращающую PreviewThemeData(materialLight: myTheme, materialDark: myThemeDark).

  • Класс PreviewThemeData предназначен для хранения отдельных конфигураций темы как для Material, так и для Cupertino, причем отдельно для светлого и темного вариантов:

base class PreviewThemeData {
  const PreviewThemeData({
    this.materialLight,
    this.materialDark,
    this.cupertinoLight,
    this.cupertinoDark,
  });

  final ThemeData? materialLight;
  final ThemeData? materialDark;
  final CupertinoThemeData? cupertinoLight;
  final CupertinoThemeData? cupertinoDark;

  // Автоматически выбирает нужную тему в зависимости от яркости
  (ThemeData?, CupertinoThemeData?) themeForBrightness(Brightness brightness) {
    if (brightness == Brightness.light) {
      return (materialLight, cupertinoLight);
    }
    return (materialDark, cupertinoDark);
  }
}

Такая схема делает переключение тем бесшовным: при переключении между светлой и темной темой Previewer вызывает themeForBrightness().

wrapper: пожалуй, один из самых мощных параметров. Он позволяет обернуть предпросматриваемый виджет в другой виджет. Например, если ваш виджет зависит от унаследованного виджета, такого как тема или локализация, передаваемые выше по дереву, можно указать wrapper: (child) => MyAppShell(child: child), чтобы предпросмотр получил весь необходимый контекст. Благодаря этому можно показывать даже сложные виджеты, которые ожидают наличия определенных предков в дереве.

Система wrapper устроена особенно изящно. В классе PreviewBuilder есть метод addWrapper(), который позволяет компоновать несколько оберток друг с другом:

final class PreviewBuilder {
  String? group;
  String? name;
  Size? size;
  double? textScaleFactor;
  WidgetWrapper? wrapper;
  PreviewTheme? theme;
  Brightness? brightness;
  PreviewLocalizations? localizations;

  // Компонует обертки: новая обертка оборачивает уже существующую
  void addWrapper(WidgetWrapper newWrapper) {
    final existingWrapper = wrapper;
    if (existingWrapper != null) {
      // Вкладывает их друг в друга: newWrapper( existingWrapper( widget ) )
      wrapper = (Widget widget) => newWrapper(existingWrapper(widget));
    } else {
      wrapper = newWrapper;
    }
  }

  Preview build() {
    return Preview._required(
      group: group ?? 'Default',
      name: name,
      size: size,
      textScaleFactor: textScaleFactor,
      wrapper: wrapper,
      theme: theme,
      brightness: brightness,
      localizations: localizations,
    );
  }
}

Если вызвать addWrapper() несколько раз, каждая новая обертка будет оборачивать предыдущую, формируя вложенное дерево виджетов. Именно этот паттерн композиции и позволяет методу transform() динамически собирать сложные конфигурации предпросмотра во время выполнения.

@Preview(
  name: 'Input in Context',
  wrapper: appWrapper, // 👈 публичная функция, допустима в аннотации
)
Widget inputPreview() => const MyTextField();

Widget appWrapper(Widget child) {
  return Scaffold(
    body: Padding(
      padding: const EdgeInsets.all(16),
      child: child,
    ),
  );
}

textScaleFactor: задает масштаб текста для предпросмотра. Используйте его, чтобы имитировать настройки доступности, например увеличенный текст. К примеру, textScaleFactor: 2.0 примерно соответствует системной настройке размера шрифта на 200%.

group: строка, которая используется для группировки связанных предпросмотров в UI. Если у вас есть несколько предпросмотров, логически относящихся к одной группе, например разные состояния одного и того же виджета, одинаковое имя группы соберет их под одним сворачиваемым заголовком в Previewer.

@Preview(name: 'Normal', group: 'Inputs')
Widget inputNormal() => const TextField();

@Preview(name: 'Error', group: 'Inputs')
Widget inputError() => const TextField(decoration: InputDecoration(errorText: 'Fail'));

С помощью этих параметров можно покрыть очень широкий набор сценариев.

Вот практический пример, в котором поле ввода тестируется при разных размерах и масштабах текста:

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

PreviewThemeData inputPreviewTheme() => PreviewThemeData(
  materialLight: ThemeData(
    brightness: Brightness.light,
    useMaterial3: true,
    colorSchemeSeed: Colors.blue,
  ),
  materialDark: ThemeData(
    brightness: Brightness.dark,
    useMaterial3: true,
    colorSchemeSeed: Colors.deepPurple,
  ),
);

@Preview(
  name: 'Normal - Light',
  group: 'Input Field Variations',
  brightness: Brightness.light,
  theme: inputPreviewTheme,
  textScaleFactor: 1.0,
)
Widget inputFieldNormalLight() {
  return const TextField(
    decoration: InputDecoration(
      border: OutlineInputBorder(),
      labelText: 'Enter text',
    ),
  );
}

@Preview(
  name: 'Normal - Dark',
  group: 'Input Field Variations',
  brightness: Brightness.dark,
  theme: inputPreviewTheme,
  textScaleFactor: 1.0,
)
Widget inputFieldNormalDark() {
  return const TextField(
    decoration: InputDecoration(
      border: OutlineInputBorder(),
      labelText: 'Enter text',
    ),
  );
}

@Preview(
  name: 'Large Text - Light',
  group: 'Input Field Variations',
  brightness: Brightness.light,
  theme: inputPreviewTheme,
  textScaleFactor: 1.5,
)
Widget inputFieldLargeTextLight() {
  return const TextField(
    decoration: InputDecoration(
      border: OutlineInputBorder(),
      labelText: 'Enter text',
    ),
  );
}

@Preview(
  name: 'Large Text - Dark',
  group: 'Input Field Variations',
  brightness: Brightness.dark,
  theme: inputPreviewTheme,
  textScaleFactor: 1.5,
)
Widget inputFieldLargeTextDark() {
  return const TextField(
    decoration: InputDecoration(
      border: OutlineInputBorder(),
      labelText: 'Enter text',
    ),
  );
}

Все четыре варианта появятся в одной группе. Вы сможете сравнивать их бок о бок, не меняя настройки вручную между проверками. Это полная противоположность ручной навигации по приложению.

Параметр wrapper — ключевой инструмент для передачи состояния и контекста

Большинство реальных виджетов зависят от контекста: данных темы, унаследованного состояния, провайдеров, локализации. Именно здесь параметр wrapper становится незаменимым.

Без корректного контекста многие виджеты просто не будут отрисовываться как нужно. Параметр wrapper позволяет встроить ваш виджет в полноценное дерево виджетов:

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

@Preview(
  name: 'Input in Form Context',
  wrapper: formContextWrapper,
)
Widget inputInFormPreview() {
  return const TextField(
    decoration: InputDecoration(
      border: OutlineInputBorder(),
      labelText: 'Username',
      helperText: 'Enter your username',
    ),
  );
}

// Wrapper: без MaterialApp, только контекст, похожий на форму, вокруг поля.
Widget formContextWrapper(Widget child) {
  return Scaffold(
    appBar: AppBar(title: const Text('Form Example')),
    body: SingleChildScrollView(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: child,
      ),
    ),
  );
}

Теперь ваше поле ввода будет отрисовываться с корректным Material-контекстом, AppBar, Scaffold и отступами — то есть со всем, что нужно, чтобы увидеть, как оно реально выглядит в приложении, но без необходимости вручную проходить по экранам.

Несколько предпросмотров помогают находить скрытые пограничные случаи

Вот мощный прием: создавайте несколько конфигураций предпросмотра для разных состояний виджета. Именно здесь Widget Previewer помогает выявить ошибки, которые легко пропустить при ручной проверке:

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

@Preview(name: 'Empty State', group: 'Input States', wrapper: inputStateWrapper)
Widget inputEmptyStatePreview() {
  return const TextField(
    decoration: InputDecoration(
      border: OutlineInputBorder(),
      labelText: 'Username',
    ),
  );
}

@Preview(name: 'With Value', group: 'Input States', wrapper: inputStateWrapper)
Widget inputWithValuePreview() {
  // Простой controller только для предпросмотра.
  final controller = TextEditingController(text: 'dcm.dev');
  return TextField(
    controller: controller,
    decoration: const InputDecoration(
      border: OutlineInputBorder(),
      labelText: 'Username',
    ),
  );
}

@Preview(name: 'Error State', group: 'Input States', wrapper: inputStateWrapper)
Widget inputErrorStatePreview() {
  return const TextField(
    decoration: InputDecoration(
      border: OutlineInputBorder(),
      labelText: 'Username',
      errorText: 'Required',
    ),
  );
}

@Preview(
  name: 'Disabled State',
  group: 'Input States',
  wrapper: inputStateWrapper,
)
Widget inputDisabledStatePreview() {
  return const TextField(
    enabled: false,
    decoration: InputDecoration(
      border: OutlineInputBorder(),
      labelText: 'Username',
    ),
  );
}

// Небольшая нейтральная обертка: делает отображение компактным и выровненным по центру.
Widget inputStateWrapper(Widget child) {
  return Center(
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 320),
        child: child,
      ),
    ),
  );
}

Здесь проявляется одно ограничение: «одна функция предпросмотра не может возвращать разные виджеты для каждого отдельного предпросмотра».

Именно здесь @MultiPreview становится незаменимым.

Пользовательские аннотации предпросмотра для устранения дублирования (DRY) в коде

В Flutter 3.38 класс Preview сделали расширяемым. Это позволяет создавать собственные аннотации предпросмотра и избавляться от шаблонного кода:

Как устроены MultiPreview и transform()

Абстрактный базовый класс MultiPreview предназначен для генерации нескольких экземпляров Preview из одной аннотации:

abstract base class MultiPreview {
  const MultiPreview();

  // Подкласс должен предоставить это: const-список конфигураций Preview
  List<Preview> get previews;

  // Точка расширения во время выполнения: преобразует каждый preview перед использованием
  List<Preview> transform() {
    return previews.map((preview) => preview.transform()).toList();
  }
}

Вы расширяете этот класс и через геттер previews передаете список объектов Preview. Но настоящая магия происходит в методе transform(). Хотя сама аннотация должна быть константной, transform() выполняется уже во время работы программы, что дает возможность программно изменять предпросмотры:

final class MyCustomPreview extends MultiPreview {
  const MyCustomPreview({required this.baseName});

  final String baseName;

  @override
  List<Preview> get previews => const [
    Preview(brightness: Brightness.light),
    Preview(brightness: Brightness.dark),
  ];

  @override
  List<Preview> transform() {
    final basePreviews = super.transform();

    return basePreviews.asMap().entries.map((entry) {
      final index = entry.key;
      final preview = entry.value;

      // Преобразуем в изменяемый builder
      final builder = preview.toBuilder();

      // Меняем во время выполнения (в const-контексте это невозможно)
      builder.name = '$baseName - ${index == 0 ? "Light" : "Dark"}';
      builder.group = 'Custom Group';
      builder.addWrapper((child) => Padding(
        padding: const EdgeInsets.all(16),
        child: child,
      ));

      // Собираем измененный preview
      return builder.build();
    }).toList();
  }
}

У каждого Preview есть метод toBuilder(), который преобразует его в изменяемый PreviewBuilder. В нем можно менять любые свойства, добавлять обертки и даже собирать сложные конфигурации, которые были бы невозможны в const-контексте.

Именно этот двухэтапный подход — сначала константная аннотация, потом преобразование во время выполнения — и делает пользовательские аннотации предпросмотра такими мощными. Структуру вы задаете на этапе компиляции, а затем дополняете ее динамическим поведением во время работы программы.

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

/// Общая тема для светлого и темного предпросмотров.
/// При использовании в const Preview должна быть публичной функцией верхнего уровня.
PreviewThemeData brightnessPreviewTheme() => PreviewThemeData(
  materialLight: ThemeData(
    brightness: Brightness.light,
    useMaterial3: true,
    colorSchemeSeed: Colors.blue,
  ),
  materialDark: ThemeData(
    brightness: Brightness.dark,
    useMaterial3: true,
    colorSchemeSeed: Colors.deepPurple,
  ),
);

/// Пользовательский предпросмотр, который автоматически создает варианты
/// для светлой и темной темы.
final class BrightnessPreview extends MultiPreview {
  const BrightnessPreview({required this.name});

  final String name;

  @override
  List<Preview> get previews => const [
    Preview(
      brightness: Brightness.light,
      theme: brightnessPreviewTheme,
      group: 'Theme',
    ),
    Preview(
      brightness: Brightness.dark,
      theme: brightnessPreviewTheme,
      group: 'Theme',
    ),
  ];

  @override
  List<Preview> transform() {
    final base = super.transform();

    final lightBuilder = base[0].toBuilder()..name = '$name - Light';
    final darkBuilder = base[1].toBuilder()..name = '$name - Dark';

    return [lightBuilder.build(), darkBuilder.build()];
  }
}

//  Использование: без повторяющегося кода.
@BrightnessPreview(name: 'Input Field')
Widget inputFieldPreview() {
  return Builder(
    builder: (context) {
      final scheme = Theme.of(context).colorScheme;

      return Center(
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 320),
          child: TextField(
            decoration: InputDecoration(
              filled: true,
              fillColor: scheme.surface,
              border: const OutlineInputBorder(),
              labelText: 'Enter text',
            ),
          ),
        ),
      );
    },
  );
}

Вместо того чтобы каждый раз вручную описывать предпросмотры для светлой и темной темы, вы один раз задаете шаблон и затем переиспользуете его везде. Для команд, которые строят дизайн-системы, это меняет правила игры.

А вот уже более продвинутый прием: у Preview есть метод transform(), который позволяет изменять предпросмотры во время выполнения. Благодаря этому становятся возможны вещи, которые нельзя сделать в const-контексте:

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

final class AccessibilityPreview extends MultiPreview {
  const AccessibilityPreview();

  @override
  List<Preview> get previews => const [
        Preview(name: 'Normal'),
        Preview(name: 'Large Text', textScaleFactor: 1.5),
        Preview(name: 'Extra Large Text', textScaleFactor: 2.0),
      ];

  @override
  List<Preview> transform() {
    final previews = super.transform();
    return previews.map((preview) {
      final builder = preview.toBuilder();
      builder.group = 'Accessibility';
      return builder.build();
    }).toList();
  }
}

@AccessibilityPreview()
Widget accessibleInputPreview() {
  return const TextField(
    decoration: InputDecoration(
      border: OutlineInputBorder(),
      labelText: 'Username',
    ),
  );
}

Компонент поля ввода для дизайн-системы

Давайте соберем все вместе. Представьте, что вы поддерживаете дизайн-систему с собственным компонентом поля ввода. Такой подход позволяет держать все состояния компонента в одном месте и просматривать их там же, чтобы они оставались согласованными по мере развития дизайна.

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

class DesignSystemInput extends StatelessWidget {
  final String label;
  final String? errorText;
  final bool enabled;
  final String? helperText;

  const DesignSystemInput({
    super.key,
    required this.label,
    this.errorText,
    this.enabled = true,
    this.helperText,
  });

  @override
  Widget build(BuildContext context) {
    return TextField(
      enabled: enabled,
      decoration: InputDecoration(
        border: const OutlineInputBorder(),
        labelText: label,
        errorText: errorText,
        helperText: helperText,
      ),
    );
  }
}

/// Общая тема для всех предпросмотров в этом MultiPreview.
/// ВАЖНО: должна быть публичной функцией верхнего уровня, потому что используется в аннотациях.
PreviewThemeData designSystemPreviewTheme() => PreviewThemeData(
  materialLight: ThemeData(
    brightness: Brightness.light,
    useMaterial3: true,
    colorSchemeSeed: Colors.blue,
  ),
  materialDark: ThemeData(
    brightness: Brightness.dark,
    useMaterial3: true,
    colorSchemeSeed: Colors.deepPurple,
  ),
);

// Определяем пользовательскую аннотацию предпросмотра для единообразия
final class DesignSystemInputPreview extends MultiPreview {
  const DesignSystemInputPreview();

  @override
  List<Preview> get previews => const [
    Preview(
      name: 'Default - Light',
      brightness: Brightness.light,
      group: 'States',
      theme: designSystemPreviewTheme,
    ),
    Preview(
      name: 'Default - Dark',
      brightness: Brightness.dark,
      group: 'States',
      theme: designSystemPreviewTheme,
    ),
    Preview(
      name: 'With Helper - Light',
      brightness: Brightness.light,
      group: 'States',
      theme: designSystemPreviewTheme,
    ),
    Preview(
      name: 'With Helper - Dark',
      brightness: Brightness.dark,
      group: 'States',
      theme: designSystemPreviewTheme,
    ),
    Preview(
      name: 'Error - Light',
      brightness: Brightness.light,
      group: 'States',
      theme: designSystemPreviewTheme,
    ),
    Preview(
      name: 'Error - Dark',
      brightness: Brightness.dark,
      group: 'States',
      theme: designSystemPreviewTheme,
    ),
    Preview(
      name: 'Disabled - Light',
      brightness: Brightness.light,
      group: 'States',
      theme: designSystemPreviewTheme,
    ),
    Preview(
      name: 'Disabled - Dark',
      brightness: Brightness.dark,
      group: 'States',
      theme: designSystemPreviewTheme,
    ),
  ];

  @override
  List<Preview> transform() {
    final previews = super.transform();
    return previews.map((preview) {
      final builder = preview.toBuilder();
      final name = preview.name ?? '';

      if (name.contains('Helper')) {
        builder.wrapper = _helperWrapper;
      } else if (name.contains('Error')) {
        builder.wrapper = _errorWrapper;
      } else if (name.contains('Disabled')) {
        builder.wrapper = _disabledWrapper;
      } else {
        builder.wrapper = _defaultWrapper;
      }

      return builder.build();
    }).toList();
  }
}

// Функции-обертки для разных состояний
Widget _defaultWrapper(Widget child) {
  // Previewer предоставляет MaterialApp + тему; мы лишь добавляем компоновку.
  return Scaffold(
    body: Padding(padding: const EdgeInsets.all(16), child: child),
  );
}

Widget _helperWrapper(Widget child) {
  return const Scaffold(
    body: Padding(
      padding: EdgeInsets.all(16),
      child: DesignSystemInput(
        label: 'Email',
        helperText: 'Enter your email address',
      ),
    ),
  );
}

Widget _errorWrapper(Widget child) {
  return const Scaffold(
    body: Padding(
      padding: EdgeInsets.all(16),
      child: DesignSystemInput(
        label: 'Email',
        errorText: 'Invalid email format',
      ),
    ),
  );
}

Widget _disabledWrapper(Widget child) {
  return const Scaffold(
    body: Padding(
      padding: EdgeInsets.all(16),
      child: DesignSystemInput(label: 'Email', enabled: false),
    ),
  );
}

// Предпросмотр
@DesignSystemInputPreview()
Widget designSystemInputPreview() {
  // Используется для вариантов "Default"; остальные состояния переопределяются через wrapper.
  return const DesignSystemInput(label: 'Email Address');
}

Теперь каждый раз, когда вы изменяете компонент DesignSystemInput, все восемь его вариантов мгновенно отрисовываются в Previewer. Вы видите все сочетания состояний и тем без ручной навигации по приложению.

Вы один раз меняете виджет и сразу видите каждый его вариант: в светлой и темной теме, а также в состояниях ошибки, подсказки и отключения.

Именно это и убирает проблему «трения при навигации». Вы больше не просто тестируете компоненты, а системно их проверяете.

Мои правила успешной работы с Widget Previewer

На основе реального опыта использования и ограничений, заложенных в widget_previews.dart — публичные или статические обратные вызовы для const-аннотаций, группа Default по умолчанию, композиция оберток во время выполнения, — вот набор правил, который действительно упрощает жизнь:

✅ Обязательно делайте предпросмотры для любых виджетов, которым не нужны аргументы конструктора. Функции верхнего уровня, статические методы и конструкторы без параметров подходят идеально.

✅ Обязательно используйте параметр wrapper, чтобы передавать MaterialApp, Scaffold, контекст темы или любую другую зависимость, необходимую вашему виджету. Обратные вызовы для wrapper должны быть публичными или статическими, чтобы на них можно было ссылаться из const-аннотаций.

✅ Обязательно создавайте пользовательские аннотации предпросмотра, (extends Preview или extends MultiPreview), если у вас повторяются одни и те же шаблоны предпросмотра. Это помогает избежать дублирования в кодовой базе и упрощает поддержку.

✅ Обязательно используйте group для организации связанных предпросмотров. По мере роста библиотеки компонентов это делает интерфейс Previewer гораздо удобнее.

✅ Обязательно проверяйте варианты доступности через textScaleFactor. Это помогает рано замечать проблемы адаптивности.

⛔️ Не рассчитывайте, что виджеты с обязательными аргументами конструктора удастся показать в предпросмотре без настройки wrapper.

⛔️ Не используйте относительные пути к ресурсам. Всегда указывайте пути через пакет, например 'packages/my_package/assets/image.png'.

⛔️ Не полагайтесь на API dart:io или dart:ffi внутри виджетов, которые вы показываете в предпросмотре. В веб-окружении Previewer они не будут работать. Если у виджета есть платформенно-зависимый код, используйте условные импорты.

⛔️ Не пытайтесь делать предпросмотр виджетов, жестко завязанных на нативные возможности платформы, такие как камера, датчики или файловая система. В веб-окружении они просто не отрисуются.

От предпросмотра — к поддерживаемым виджетам

Widget Previewer решает проблему видимости: вы мгновенно видите все состояния. Но по мере роста библиотеки компонентов возникает новая проблема — поддерживаемость и качество виджетов.

В какой-то момент у вас появляются 20 похожих кнопок, 15 вариантов карточек и 8 полей ввода, которые почти не отличаются друг от друга. Одни виджеты разрастаются в 300-строчные монстры с запредельной цикломатической сложностью. Другие просто копируются из файла в файл, потому что так быстрее, чем рефакторить. Дизайн-система начинает расползаться на части.

Именно здесь становится особенно полезной команда analyze-widgets из DCM. Это инструмент статического анализа, специально созданный для слоя виджетов во Flutter. Он отвечает на вопросы, на которые одних только предпросмотров уже недостаточно:

  • Не слишком ли сложный этот виджет? DCM вычисляет показатели качества на основе метрик кода — цикломатической сложности, количества строк, глубины вложенности и других — и отмечает виджеты, которые стали слишком большими или запутанными.

  • Где используется этот виджет? Инструмент показывает, как виджет используется по всей кодовой базе, помогая понять радиус возможных последствий от изменений.

  • Есть ли у меня дублирующиеся виджеты? С флагом --show-similarity DCM находит почти одинаковые поддеревья виджетов — те самые результаты копирования и вставки, которые, возможно, давно стоило вынести в общие компоненты.

Думайте о нем как о проверке состояния архитектуры ваших виджетов. Предпросмотры позволяют быстро вносить изменения. DCM позволяет делать это устойчиво.

# Базовый отчет о качестве виджетов
dcm analyze-widgets lib

# Поиск похожего UI, который стоит отрефакторить (по умолчанию отключено)
dcm analyze-widgets lib --show-similarity --reporter=html --open

Поиск сходства особенно полезен для дизайн-систем. Он выделяет виджеты с совпадающей структурой метода build и даже показывает визуальные различия между дублирующимися поддеревьями. Это помогает находить возможности для рефакторинга, которые иначе легко упустить.

Когда Widget Previewer становится вашим конкурентным преимуществом

Если вы строите дизайн-систему или библиотеку компонентов, Widget Previewer — это уже не просто полезное дополнение, а необходимая инфраструктура.

Без него ваша дизайн-система существует в виде абстрактной документации и разрозненных примеров кода. С ним каждый компонент превращается в живую, интерактивную документацию. Дизайнеры могут видеть все варианты. Другие разработчики могут понять, как использовать компоненты, просто наблюдая их в работе. Пограничные случаи начинают проявляться сами собой.

Именно это дает Storybook в веб-разработке. Именно это встроено в Jetpack Compose. Теперь такой инструмент есть и у Flutter-разработчиков.

Заключение

Hot Reload во Flutter уже изменил правила игры. Он сделал разработку быстрой. Widget Previewer завершает картину. Он делает разработку сфокусированной.

Вместо цикла «скомпилировать → перейти → посмотреть → повторить» вы получаете мгновенную изоляцию и мгновенную обратную связь. Компонент становится центром внимания, а не маленьким фрагментом, спрятанным где-то в навигационной иерархии приложения. И что еще важнее, вы можете просто создавать свои дизайн-системы отдельно от бизнес-логики, так, чтобы они работали независимо.

Ваша библиотека компонентов станет лучше. Дизайн-система станет сильнее. А цикл итераций наконец начнет ощущаться современным.

Удачных предпросмотров.

Если вам нужен не просто быстрый предпросмотр, а системная работа с Flutter, стоит смотреть шире самого инструмента. На курсе Otus «Flutter-разработчик» разбирают кроссплатформенную разработку с единой кодовой базой, архитектуру приложения и практику работы с интерфейсами, состоянием и структурой проекта. Пройдите вступительный тест, чтобы узнать, подойдет ли вам программа курса.

Для знакомства с форматом обучения и экспертами приходите на бесплатные уроки:

  • 25 марта в 20:00. «Как писать Flutter-код так, чтобы ИИ правильно его дописывал». Записаться

  • 13 апреля в 20:00. «Flutter GenUI: когда ИИ-агент собирает ваш интерфейс». Записаться