1. はじめに
- 日々、FlutterでのUIをしていますが効率が落ちている部分があることに気がつきました
- エミュレータ起動が遅いので待ち時間が発生
- 画面遷移しないと Widget の見た目を確認できないので操作が必要です
- 状態毎に表示確認が面倒
- Hot reloadで状態を切り替えたりするなど
- このような問題を解消するためにFlutter Widget Previewerを使った方法を紹介します
2. 前提条件・環境
- Flutter 3.38.3
- Dart 3.10.1
- IDE:VS Code
3. Widget Previewer とは?
- Widget をフルアプリで起動せずに Chrome ブラウザ上でプレビューできる仕組みです
- Jetpack Compose や SwiftUIであるPreview の Flutter 版
- Flutter Widget Preview は Flutter
3.35以上が必要です - IDE 連携(プレビュー操作や補助機能)は Flutter
3.38以上が必要です - この機能は Flutter stable チャンネルの「実験的機能」で、APIは安定ではありません
- 現在の早期アクセス版に基づくガイドのため、今後の更新で破壊的変更が導入される可能性があります
注意: 早期アクセス機能のため将来的に破壊的変更が入る可能性があります。
4. Widget Previewer を使うメリット
1. 修正から確認のサイクルが速くなる
- エミュレータ不要なので起動時間がなくなる
- 画面遷移不要なので該当画面まで遷移する必要はないので手間が減ります
2. コンポーネント志向になる
- プレビューしやすくするためにWidgetの細分化が進み、再利用性が上がる
- 状態を差し替え可能になり、テストしやすくなります
3. Golden Test と相性がよい
- コンポーネント化が進むのでGolden Testにそのまま活用することができます
5. セットアップ手順
公式の Flutter Widget Previewer に沿った手順を記載します(参考: https://docs.flutter.dev/tools/widget-previewer)。
1. Previewer を起動する
IDE (VS Code / Android Studio / IntelliJ) では Flutter 3.38 以上で自動的に起動し、サイドバーに「Flutter Widget Preview」タブが表示されます。
CLI から起動する場合はプロジェクトルートで以下を実行:
flutter widget-preview start
Chrome が開き、変更が自動反映され、ローカルサーバが立ち上がります。
2. プレビュー対象を定義する (@Preview アノテーション)
package:flutter/widget_previews.dart が提供する @Preview を付けることで、Widget を一覧に表示できます。
可能な対象
- 戻り値が
WidgetもしくはWidgetBuilderのトップレベル関数 - 戻り値が
Widget/WidgetBuilderのクラス内 static メソッド - 必須引数のない公開 Widget コンストラクタ / ファクトリ
基本例:
import 'package:flutter/material.dart';
import 'package:flutter/widget_previews.dart';
@Preview(name: 'Sample Text')
Widget sampleText() => const Text('Hello, World!');
6. 工夫している点
ここからプレビューを UI 実装に組み込むための工夫している点についてです。
1. @Preview を拡張する
モバイルアプリですと端末のサイズが様々あります。見た目を実際の端末サイズにしてプレビューすることができるように工夫しています。よく使う端末をまとめることもできます。
brightness を指定することでダークモードの UI もチェックできます。
import 'package:flutter/material.dart';
import 'package:flutter/widget_previews.dart';
/// よく使う端末サイズを列挙
enum DevicePreset {
// iPhone (approx logical pixels)
iphone15Pro(Size(393, 852)),
iphone15ProMax(Size(430, 932)),
// Android (Pixel series)
pixel8(Size(412, 915)),
pixel8Pro(Size(412, 915)),
// iPad Pro (portrait logical size)
ipadPro11(Size(834, 1194)),
ipadPro129(Size(1024, 1366));
const DevicePreset(this.size);
final Size size;
}
/// 端末サイズを適用したプレビューをまとめて生成する MultiPreview
final class DeviceMultiPreview extends MultiPreview {
const DeviceMultiPreview({required this.group, required this.devices});
final String group;
final List<DevicePreset> devices;
@override
List<Preview> get previews => devices
.map((d) => Preview(group: group, name: d.name, size: d.size))
.toList();
}
/// テーマ(明暗)も合わせて試したい場合の複合アノテーション例
final class DeviceBrightnessMultiPreview extends MultiPreview {
const DeviceBrightnessMultiPreview({required this.group, required this.devices});
final String group;
final List<DevicePreset> devices;
@override
List<Preview> get previews => [
for (final d in devices)
Preview(group: group, name: '${d.name} (light)', size: d.size, brightness: Brightness.light),
for (final d in devices)
Preview(group: group, name: '${d.name} (dark)', size: d.size, brightness: Brightness.dark),
];
}
2. コンポーネント化しやすいように Screen と Content に Widget を分ける
依存が少ない画面なら簡単ですが多いとFakeやmockに差し替えるのが大変になるので
管理するWidgetと表示するWidgetに分けて、依存を減らす工夫をしている。
下記にサンプルコードを記載しました
Before(依存がUIに直書きされている例)
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context) {
final repo = RealProfileRepository(); // ここで具体実装を直接生成
final user = repo.currentUser();
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(user.name),
Text(user.email),
const Spacer(),
ElevatedButton(
onPressed: () async {
await repo.signOut();
},
child: const Text('Sign out'),
),
],
),
),
);
}
}
問題点:
- 依存が UI に直結しており差し替え困難
-
@Previewでの単体表示が難しい(外部状態に強く依存)
After(Screen/Content分離で依存を隔離)
abstract class ProfileRepository {
User currentUser();
void signOut();
}
class User {
final String name;
final String email;
User({required this.name, required this.email});
}
// 依存は Screen 側で受け、UI は Content に委譲
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key, required this.repo});
final ProfileRepository repo;
@override
Widget build(BuildContext context) {
final user = repo.currentUser();
return ProfileContent(name: user.name, email: user.email, onSignOut: () => repo.signOut());
}
}
class ProfileContent extends StatelessWidget {
const ProfileContent({super.key, required this.name, required this.email, required this.onSignOut});
final String name;
final String email;
final VoidCallback onSignOut;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 8),
Text(email),
const Spacer(),
ElevatedButton(onPressed: onSignOut, child: const Text('Sign out')),
],
),
),
);
}
}
// Preview は純粋 UI (Content) だけで可能
@Preview(group: 'Profile', name: 'Content - default')
Widget profileContentPreview() => const ProfileContent(name: 'Jane Doe', email: 'jane@example.com', onSignOut: _noop);
void _noop() {}
7. まとめ
- Flutter Widget PreviewはUI開発の生産性をアップに貢献します
- コンポーネント指向が進み、テスタブルな設計になり、Goldenテストにも活用しやすい
- Previewの独自拡張もしやすく、今後の改善に期待です
- 導入も軽いので小さなWidgetから試せるのでおすすめです
参考リンク
- Flutter 公式ドキュメント
