Skip to content

WidgetBook을 활용한 Flutter UI 문서화

Flutter 애플리케이션을 개발할 때 일관된 디자인 시스템을 유지하고 UI 컴포넌트를 문서화하는 것은 중요합니다. WidgetBook은 Flutter 위젯을 카탈로그화하고 대화형으로 테스트할 수 있는 도구로, Storybook이나 Styleguidist와 같은 웹 개발 도구에서 영감을 받았습니다. 이 문서에서는 WidgetBook을 설정하고 효과적으로 활용하는 방법에 대해 알아보겠습니다.

WidgetBook은 다음과 같은 기능을 제공하는 Flutter 패키지입니다:

  • UI 컴포넌트 문서화 및 카탈로그화
  • 다양한 데이터 및 상태로 위젯 테스트
  • 디자인 시스템 구축 및 유지보수
  • 디자이너와 개발자 간 협업 촉진
  • 위젯의 반응형 동작 시각화

먼저 pubspec.yaml 파일에 필요한 패키지를 추가합니다:

dependencies:
flutter:
sdk: flutter
# 기타 의존성...
dev_dependencies:
flutter_test:
sdk: flutter
widgetbook: ^3.0.0 # 위젯북 코어 패키지
widgetbook_generator: ^3.0.0 # 코드 생성 도구
build_runner: ^2.1.10 # 코드 생성 실행기

WidgetBook을 구성하기 위한 기본 파일을 생성합니다:

독립적인 WidgetBook 앱을 위한 진입점을 생성합니다. 프로젝트 루트에 widgetbook.dart 파일을 생성합니다:

widgetbook.dart
import 'package:flutter/material.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:your_app/theme.dart'; // 앱 테마 임포트
void main() {
runApp(const WidgetbookApp());
}
class WidgetbookApp extends StatelessWidget {
const WidgetbookApp({super.key});
@override
Widget build(BuildContext context) {
return Widgetbook.material(
// 앱 정보 설정
appInfo: AppInfo(name: 'MyApp 컴포넌트'),
// 테마 설정
themes: [
WidgetbookTheme(
name: '라이트 테마',
data: AppTheme.lightTheme,
),
WidgetbookTheme(
name: '다크 테마',
data: AppTheme.darkTheme,
),
],
// 디바이스 프레임 설정
devices: [
Apple.iPhone13,
Samsung.s21ultra,
const DeviceInfo.custom(
name: '태블릿',
resolution: Resolution(
nativeSize: DeviceSize(width: 1024, height: 768),
scaleFactor: 1,
),
),
],
// 위젯 카테고리 설정
categories: [
WidgetbookCategory(
name: '기본 컴포넌트',
widgets: [
// 여기에 위젯 사용 사례 추가
],
categories: [
WidgetbookCategory(
name: '버튼',
widgets: [
// 버튼 위젯 사용 사례
],
),
WidgetbookCategory(
name: '입력 필드',
widgets: [
// 입력 필드 위젯 사용 사례
],
),
],
),
],
);
}
}

package.json 파일이나 Makefile에 WidgetBook 실행 명령어를 추가합니다:

{
"scripts": {
"widgetbook": "flutter run -d chrome -t widgetbook.dart"
}
}

WidgetBook은 코드 생성을 통해 설정의 복잡성을 줄일 수 있습니다. 다음과 같이 설정합니다:

1. widgetbook_component.dart 파일 생성

Section titled “1. widgetbook_component.dart 파일 생성”

프로젝트에 lib/widgetbook/widgetbook_component.dart 파일을 생성합니다:

import 'package:flutter/material.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
// 코드 생성을 위한 애너테이션
@widgetbook.App()
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return Container();
}
}

프로젝트 루트에 build.yaml 파일을 생성하여 코드 생성 설정을 추가합니다:

targets:
$default:
builders:
widgetbook_generator:
options:
output_directory: lib/widgetbook
generator_type: widgetbook

다음 명령어로 코드를 생성합니다:

Terminal window
flutter pub run build_runner build

위젯을 문서화하기 위해 사용 사례(use case)를 정의합니다:

import 'package:flutter/material.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase(
name: '기본 버튼',
type: ElevatedButton,
designLink: 'https://www.figma.com/file/...',
)
Widget elevatedButtonUseCase(BuildContext context) {
return Center(
child: ElevatedButton(
onPressed: () {},
child: const Text('기본 버튼'),
),
);
}
@widgetbook.UseCase(
name: '비활성화된 버튼',
type: ElevatedButton,
)
Widget disabledElevatedButtonUseCase(BuildContext context) {
return Center(
child: ElevatedButton(
onPressed: null, // null은 버튼을 비활성화함
child: const Text('비활성화된 버튼'),
),
);
}
@widgetbook.UseCase(
name: '아이콘 버튼',
type: ElevatedButton,
)
Widget iconElevatedButtonUseCase(BuildContext context) {
return Center(
child: ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.favorite),
label: const Text('좋아요'),
),
);
}
import 'package:flutter/material.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase(
name: '기본 텍스트 필드',
type: TextField,
)
Widget textFieldUseCase(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: TextField(
decoration: InputDecoration(
labelText: '이름',
hintText: '이름을 입력하세요',
),
),
);
}
@widgetbook.UseCase(
name: '비밀번호 텍스트 필드',
type: TextField,
)
Widget passwordTextFieldUseCase(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: TextField(
obscureText: true,
decoration: InputDecoration(
labelText: '비밀번호',
hintText: '비밀번호를 입력하세요',
suffixIcon: Icon(Icons.visibility),
),
),
);
}
@widgetbook.UseCase(
name: '오류 상태 텍스트 필드',
type: TextField,
)
Widget errorTextFieldUseCase(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: TextField(
decoration: InputDecoration(
labelText: '이메일',
hintText: '이메일을 입력하세요',
errorText: '유효한 이메일 주소를 입력해주세요',
),
),
);
}

매개변수 제어를 위한 Knobs 사용

Section titled “매개변수 제어를 위한 Knobs 사용”

Knobs를 사용하면 UI를 통해 위젯의 매개변수를 동적으로 변경할 수 있습니다:

import 'package:flutter/material.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase(
name: '커스터마이징 가능한 버튼',
type: ElevatedButton,
)
Widget customizableButtonUseCase(BuildContext context) {
// 텍스트 Knob
final buttonText = context.knobs.text(
label: '버튼 텍스트',
initialValue: '버튼',
);
// 색상 Knob
final buttonColor = context.knobs.color(
label: '버튼 색상',
initialValue: Colors.blue,
);
// 숫자 Knob (크기 조절)
final fontSize = context.knobs.number(
label: '글자 크기',
initialValue: 16,
min: 10,
max: 30,
);
// 불리언 Knob (활성화/비활성화)
final isEnabled = context.knobs.boolean(
label: '활성화',
initialValue: true,
);
// 옵션 Knob (버튼 형태)
final buttonShape = context.knobs.options(
label: '버튼 형태',
options: [
Option(label: '기본', value: 0.0),
Option(label: '둥근 모서리', value: 8.0),
Option(label: '원형', value: 20.0),
],
);
return Center(
child: ElevatedButton(
onPressed: isEnabled ? () {} : null,
style: ElevatedButton.styleFrom(
backgroundColor: buttonColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(buttonShape),
),
),
child: Text(
buttonText,
style: TextStyle(fontSize: fontSize),
),
),
);
}

더 복잡한 컴포넌트나 화면을 문서화하는 방법입니다:

import 'package:flutter/material.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase(
name: '회원가입 폼',
type: SignUpForm,
designLink: 'https://www.figma.com/file/...',
)
Widget signUpFormUseCase(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: SignUpForm(),
);
}
class SignUpForm extends StatefulWidget {
const SignUpForm({super.key});
@override
State<SignUpForm> createState() => _SignUpFormState();
}
class _SignUpFormState extends State<SignUpForm> {
final _formKey = GlobalKey<FormState>();
bool _obscurePassword = true;
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'회원가입',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
TextFormField(
decoration: const InputDecoration(
labelText: '이름',
prefixIcon: Icon(Icons.person),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '이름을 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(
labelText: '이메일',
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return '이메일을 입력해주세요';
}
if (!value.contains('@')) {
return '유효한 이메일 주소를 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
decoration: InputDecoration(
labelText: '비밀번호',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
obscureText: _obscurePassword,
validator: (value) {
if (value == null || value.isEmpty) {
return '비밀번호를 입력해주세요';
}
if (value.length < 8) {
return '비밀번호는 8자 이상이어야 합니다';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// 폼 처리 로직
}
},
child: const Text('가입하기'),
),
const SizedBox(height: 16),
TextButton(
onPressed: () {},
child: const Text('이미 계정이 있으신가요? 로그인하기'),
),
],
),
);
}
}
import 'package:flutter/material.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase(
name: '제품 카드',
type: ProductCard,
)
Widget productCardUseCase(BuildContext context) {
final isDiscounted = context.knobs.boolean(
label: '할인 적용',
initialValue: false,
);
final rating = context.knobs.slider(
label: '평점',
initialValue: 4.5,
min: 1.0,
max: 5.0,
divisions: 8,
);
return Center(
child: ProductCard(
title: '스마트폰',
imageUrl: 'https://example.com/smartphone.jpg',
price: 1000000,
discountPrice: isDiscounted ? 850000 : null,
rating: rating,
onPressed: () {},
),
);
}
class ProductCard extends StatelessWidget {
final String title;
final String imageUrl;
final int price;
final int? discountPrice;
final double rating;
final VoidCallback onPressed;
const ProductCard({
super.key,
required this.title,
required this.imageUrl,
required this.price,
this.discountPrice,
required this.rating,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
elevation: 2,
child: InkWell(
onTap: onPressed,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 이미지 플레이스홀더
AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Colors.grey[300],
child: const Center(child: Icon(Icons.image)),
),
),
Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4),
if (discountPrice != null) ...[
Text(
'${price.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},')}원',
style: TextStyle(
decoration: TextDecoration.lineThrough,
color: Colors.grey[600],
),
),
Text(
'${discountPrice.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},')}원',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
] else
Text(
'${price.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},')}원',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.star, color: Colors.amber, size: 18),
const SizedBox(width: 4),
Text(rating.toStringAsFixed(1)),
],
),
],
),
),
],
),
),
);
}
}

WidgetBook을 사용하여 반응형 디자인을 테스트하는 방법:

import 'package:flutter/material.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@widgetbook.UseCase(
name: '반응형 레이아웃',
type: ResponsiveLayout,
)
Widget responsiveLayoutUseCase(BuildContext context) {
return const ResponsiveLayout(
mobileChild: MobileView(),
tabletChild: TabletView(),
desktopChild: DesktopView(),
);
}
class ResponsiveLayout extends StatelessWidget {
final Widget mobileChild;
final Widget tabletChild;
final Widget desktopChild;
const ResponsiveLayout({
super.key,
required this.mobileChild,
required this.tabletChild,
required this.desktopChild,
});
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
if (width < 600) {
return mobileChild;
} else if (width < 1200) {
return tabletChild;
} else {
return desktopChild;
}
}
}
class MobileView extends StatelessWidget {
const MobileView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('모바일 뷰')),
body: ListView(
children: List.generate(
10,
(index) => ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text('항목 ${index + 1}'),
subtitle: const Text('모바일 레이아웃'),
),
),
),
bottomNavigationBar: BottomNavigationBar(
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: '홈'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: '검색'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: '프로필'),
],
currentIndex: 0,
onTap: (_) {},
),
);
}
}
class TabletView extends StatelessWidget {
const TabletView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('태블릿 뷰')),
body: Row(
children: [
NavigationRail(
selectedIndex: 0,
onDestinationSelected: (_) {},
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('홈'),
),
NavigationRailDestination(
icon: Icon(Icons.search),
label: Text('검색'),
),
NavigationRailDestination(
icon: Icon(Icons.person),
label: Text('프로필'),
),
],
),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: 10,
itemBuilder: (context, index) {
return Card(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 30,
child: Text('${index + 1}'),
),
const SizedBox(height: 8),
Text('항목 ${index + 1}'),
const Text('태블릿 레이아웃'),
],
),
);
},
),
),
],
),
);
}
}
class DesktopView extends StatelessWidget {
const DesktopView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('데스크톱 뷰')),
body: Row(
children: [
Drawer(
child: ListView(
children: [
const DrawerHeader(
child: Text(
'메뉴',
style: TextStyle(
color: Colors.white,
fontSize: 24,
),
),
decoration: BoxDecoration(
color: Colors.blue,
),
),
ListTile(
leading: const Icon(Icons.home),
title: const Text('홈'),
selected: true,
onTap: () {},
),
ListTile(
leading: const Icon(Icons.search),
title: const Text('검색'),
onTap: () {},
),
ListTile(
leading: const Icon(Icons.person),
title: const Text('프로필'),
onTap: () {},
),
],
),
),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: 20,
itemBuilder: (context, index) {
return Card(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 30,
child: Text('${index + 1}'),
),
const SizedBox(height: 8),
Text('항목 ${index + 1}'),
const Text('데스크톱 레이아웃'),
],
),
);
},
),
),
],
),
);
}
}

WidgetBook에서 Provider, Riverpod 등의 상태 관리 도구를 사용하는 방법:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
// 카운터 상태 제공자
final counterProvider = StateProvider<int>((ref) => 0);
@widgetbook.UseCase(
name: 'Riverpod 카운터',
type: CounterWidget,
)
Widget riverpodCounterUseCase(BuildContext context) {
return ProviderScope(
child: CounterWidget(),
);
}
class CounterWidget extends ConsumerWidget {
const CounterWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$count',
style: Theme.of(context).textTheme.headline2,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).state++,
child: const Text('증가'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).state--,
child: const Text('감소'),
),
],
),
);
}
}

실제 API 데이터를 시뮬레이션하여 데이터 의존적인 위젯을 문서화하는 방법:

import 'package:flutter/material.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
// 모의 데이터 서비스
class MockDataService {
Future<List<User>> getUsers() async {
// API 호출 시뮬레이션
await Future.delayed(const Duration(seconds: 1));
return [
User(id: 1, name: '홍길동', email: 'hong@example.com'),
User(id: 2, name: '김철수', email: 'kim@example.com'),
User(id: 3, name: '이영희', email: 'lee@example.com'),
];
}
}
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
}
@widgetbook.UseCase(
name: '사용자 목록',
type: UserListWidget,
)
Widget userListUseCase(BuildContext context) {
final isLoading = context.knobs.boolean(
label: '로딩 중',
initialValue: false,
);
final hasError = context.knobs.boolean(
label: '오류 발생',
initialValue: false,
);
final isEmpty = context.knobs.boolean(
label: '빈 목록',
initialValue: false,
);
return UserListWidget(
mockService: MockDataService(),
isLoading: isLoading,
hasError: hasError,
isEmpty: isEmpty,
);
}
class UserListWidget extends StatefulWidget {
final MockDataService mockService;
final bool isLoading;
final bool hasError;
final bool isEmpty;
const UserListWidget({
super.key,
required this.mockService,
this.isLoading = false,
this.hasError = false,
this.isEmpty = false,
});
@override
State<UserListWidget> createState() => _UserListWidgetState();
}
class _UserListWidgetState extends State<UserListWidget> {
late Future<List<User>> _usersFuture;
@override
void initState() {
super.initState();
_loadUsers();
}
@override
void didUpdateWidget(UserListWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isLoading != widget.isLoading ||
oldWidget.hasError != widget.hasError ||
oldWidget.isEmpty != widget.isEmpty) {
_loadUsers();
}
}
void _loadUsers() {
if (widget.isLoading) {
_usersFuture = Future.delayed(const Duration(days: 1), () => <User>[]);
} else if (widget.hasError) {
_usersFuture = Future.error('데이터 로드 중 오류가 발생했습니다.');
} else if (widget.isEmpty) {
_usersFuture = Future.value(<User>[]);
} else {
_usersFuture = widget.mockService.getUsers();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('사용자 목록')),
body: FutureBuilder<List<User>>(
future: _usersFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('오류: ${snapshot.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => setState(() => _loadUsers()),
child: const Text('다시 시도'),
),
],
),
);
}
final users = snapshot.data ?? [];
if (users.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people, size: 48, color: Colors.grey),
SizedBox(height: 16),
Text('사용자 정보가 없습니다.'),
],
),
);
}
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return ListTile(
leading: CircleAvatar(
child: Text(user.name.substring(0, 1)),
),
title: Text(user.name),
subtitle: Text(user.email),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
);
},
);
},
),
);
}
}

위젯북을 더 효과적으로 활용하기 위한 팁:

  1. 논리적으로 카테고리화:

    • 위젯을 기능, 유형 또는 페이지별로 그룹화
    • 데이터 입력, 탐색, 표시 등으로 분류
  2. 일관된 명명 규칙:

    • 명확하고 일관된 이름으로 컴포넌트와 사용 사례 명명
    • 패턴 사용(예: “기본”, “비활성화”, “오류 상태”)
  3. 연동 문서화:

    • Figma나 Zeplin 링크 포함하여 디자인과 코드 매핑
    • 필요한 경우 추가 설명이나 사용 지침 제공
  4. 자동화 및 CI 통합:

    • 커밋 또는 병합 시 위젯북 자동 빌드
    • 위젯북 웹 버전을 팀 내부 서버에 배포

WidgetBook은 Flutter 애플리케이션의 UI 컴포넌트를 문서화하고 테스트하기 위한 강력한 도구입니다. 이를 통해 디자이너와 개발자 간의 협업을 촉진하고, 일관된 디자인 시스템을 유지할 수 있습니다. 특히 팀 규모가 커지거나 프로젝트가 복잡해질수록 WidgetBook의 가치는 더욱 커집니다.

효과적인 UI 문서화는 코드의 재사용성을 높이고, 디자인 일관성을 유지하며, 신규 팀원의 온보딩을 용이하게 하는 등 다양한 이점을 제공합니다. WidgetBook을 프로젝트에 통합하여 효율적인 UI 개발 환경을 구축해 보세요.