Flutter UI Patterns - Reference#
Provides detailed descriptions, example code, and precautions for each Use Case.
1. Page Structure#
Overview#
All pages follow a consistent structure.
Basic Structure#
class MyPage extends HookWidget {
const MyPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => MyBloc()..add(const MyEvent.started()),
child: Scaffold(
headers: [
AppBar(
title: Text('ํ์ด์ง ์ ๋ชฉ'),
leading: [
IconButton.ghost(
icon: HeroIcon(HeroIcons.arrowLeft),
onPressed: () => context.pop(),
),
],
trailing: [
IconButton.ghost(
icon: HeroIcon(HeroIcons.cog6Tooth),
onPressed: onSettings,
),
],
),
],
child: const _Body(),
),
);
}
}
class _Body extends StatelessWidget {
const _Body();
@override
Widget build(BuildContext context) {
return BlocBuilder<MyBloc, MyState>(
builder: (context, state) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: Spacing.s4),
child: Column(
children: [
// ์ปจํ
์ธ
],
),
);
},
);
}
}
Pattern Selection Criteria#
| Pattern | When to Use | Example |
|---|---|---|
| HookWidget + BlocProvider | Async data loading | Details Page, List |
| HookWidget only | Only local state needed | Settings Page, Form |
| StatelessWidget | No state | Static info Page |
Precautions#
// โ
CORRECT: BlocProvider๋ ํ์ด์ง ๋ ๋ฒจ์์ ์์ฑ
class MyPage extends HookWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => MyBloc(),
child: _Body(),
);
}
}
// โ WRONG: build ๋ฉ์๋ ๋ด์์ ๋งค๋ฒ ์์ฑ ๊ธ์ง
class MyPage extends HookWidget {
@override
Widget build(BuildContext context) {
final bloc = MyBloc(); // ๋งค๋ฒ ์๋ก ์์ฑ๋จ!
return BlocProvider.value(
value: bloc,
child: _Body(),
);
}
}
2. State Management#
BLoC Pattern Overview#
User Action โ Event โ BLoC โ State โ UI Update
BlocBuilder#
Used for UI rendering based on state.
BlocBuilder<MyBloc, MyState>(
// Optional: specify rebuild conditions
buildWhen: (previous, current) => previous.items != current.items,
builder: (context, state) {
// State branching with switch expression (recommended)
return switch (state) {
MyStateInitial() => const SizedBox.shrink(),
MyStateLoading() => const LoadingIndicator(),
MyStateSuccess(:final data) => SuccessWidget(data: data),
MyStateFailure(:final error) => ErrorWidget(message: error),
};
},
)
BlocConsumer#
Used when both UI rendering and side effects are needed.
BlocConsumer<AuthBloc, AuthState>(
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
// Side effects: navigation, snackbar, dialog
if (state.isAuthenticated) {
context.go('/home');
}
if (state.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage)),
);
}
},
buildWhen: (previous, current) => previous.formState != current.formState,
builder: (context, state) {
return LoginForm(
isLoading: state.isLoading,
isValid: state.isValid,
);
},
)
BlocListener#
Used when only side effects are needed.
BlocListener<PaymentBloc, PaymentState>(
listenWhen: (previous, current) => current.isCompleted,
listener: (context, state) {
showDialog(
context: context,
builder: (_) => SuccessDialog(),
);
},
child: PaymentForm(),
)
MultiBlocProvider#
Used in pages requiring multiple BLoCs.
MultiBlocProvider(
providers: [
BlocProvider(create: (_) => UserBloc()),
BlocProvider(create: (_) => SettingsBloc()),
BlocProvider(create: (_) => NotificationBloc()),
],
child: SettingsPage(),
)
Event Dispatching#
// โ
CORRECT: context.read ์ฌ์ฉ
Button.primary(
onPressed: () {
context.read<MyBloc>().add(const MyEvent.submitted());
},
child: Text('์ ์ถ'),
)
// โ WRONG: context.watch๋ก ์ด๋ฒคํธ ๋ฐ์ก ๊ธ์ง
Button.primary(
onPressed: () {
context.watch<MyBloc>().add(...); // ๋ถํ์ํ ๋ฆฌ๋น๋ ๋ฐ์
},
)
3. Local State (Hooks)#
Basic Hooks#
class MyWidget extends HookWidget {
@override
Widget build(BuildContext context) {
// useState - ๋จ์ ์ํ
final counter = useState(0);
final isVisible = useState(true);
// useTextEditingController - ํ
์คํธ ์
๋ ฅ
final controller = useTextEditingController();
// useEffect - ์ฌ์ด๋ ์ดํํธ
useEffect(() {
final subscription = stream.listen((value) {
counter.value = value;
});
return subscription.cancel; // cleanup
}, []); // ๋น ๋ฐฐ์ด = componentDidMount
return Column(
children: [
Text('Count: ${counter.value}'),
TextField(controller: controller),
Switch(
value: isVisible.value,
onChanged: (v) => isVisible.value = v,
),
],
);
}
}
Animation Hooks#
class AnimatedWidget extends HookWidget {
@override
Widget build(BuildContext context) {
final animationController = useAnimationController(
duration: const Duration(milliseconds: 300),
);
final animation = useAnimation(
CurvedAnimation(
parent: animationController,
curve: Curves.easeInOut,
),
);
return FadeTransition(
opacity: animation,
child: Content(),
);
}
}
useMemoized#
Caches expensive computation results.
final expensiveResult = useMemoized(
() => computeExpensiveValue(items),
[items], // ์์กด์ฑ ๋ณ๊ฒฝ ์ ์ฌ๊ณ์ฐ
);
useCallback#
Memoizes callback functions.
final onSubmit = useCallback(() {
context.read<MyBloc>().add(const MyEvent.submitted());
}, []);
4. Typography#
Text Chaining Pattern#
This project's standard text styling approach.
// ํฌ๊ธฐ
Text('ํ
์คํธ').xs // 12px
Text('ํ
์คํธ').sm // 14px
Text('ํ
์คํธ').md // 16px (default)
Text('ํ
์คํธ').lg // 18px
Text('ํ
์คํธ').xl // 20px
// ๊ตต๊ธฐ
Text('ํ
์คํธ').normal // 400
Text('ํ
์คํธ').medium // 500
Text('ํ
์คํธ').semiBold // 600
Text('ํ
์คํธ').bold // 700
// ์์
Text('ํ
์คํธ').baseContent // default ํ
์คํธ
Text('ํ
์คํธ').base200 // ๋ณด์กฐ ํ
์คํธ
Text('ํ
์คํธ').primary // Primary color
Text('ํ
์คํธ').success // ์ฑ๊ณต
Text('ํ
์คํธ').error // ์๋ฌ
// ์กฐํฉ
Text('์ ๋ชฉ').lg.bold.baseContent
Text('๋ถ์ ๋ชฉ').md.semiBold.base200
Text('๋ณธ๋ฌธ').sm.normal.baseContent
Text('์บก์
').xs.normal.base200.withOpacity(0.7)
Direct Typography Access#
Access the typography object directly in special cases.
final typography = context.theme.typography;
// ์คํ์ผ ๋ณํฉ
Text(
'ํ
์คํธ',
style: typography.lg.merge(typography.semiBold).copyWith(
letterSpacing: 0.5,
),
)
Precautions#
// โ WRONG: TextStyle ์ง์ ์ ์ ๊ธ์ง
Text(
'ํ
์คํธ',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
)
// โ
CORRECT: Text chaining ์ฌ์ฉ
Text('ํ
์คํธ').lg.bold
5. Buttons#
Button Variants#
// Primary - ์ฃผ์ ์ก์
(์ ์ถ, ํ์ธ)
Button.primary(
onPressed: onSubmit,
expanded: true, // ์ ์ฒด ๋๋น
enabled: isValid, // ํ์ฑํ ์ํ
child: Text('ํ์ธ'),
)
// Ghost - ๋ณด์กฐ ์ก์
(์ทจ์, ๋ค๋ก)
Button.ghost(
onPressed: onCancel,
child: Text('์ทจ์'),
)
// Card - ์นด๋ ํํ ๋ฒํผ
Button.card(
style: const ButtonStyle.card().copyWith(
borderRadius: RadiusScale.kBox,
),
onPressed: onTap,
child: Column(
children: [
Icon(Icons.settings),
Text('์ค์ '),
],
),
)
// Outline - ํ
๋๋ฆฌ ๋ฒํผ
Button.outline(
onPressed: onTap,
child: Text('์์ธํ ๋ณด๊ธฐ'),
)
IconButton#
IconButton.ghost(
icon: HeroIcon(HeroIcons.pencilSquare),
onPressed: onEdit,
)
IconButton.primary(
icon: HeroIcon(HeroIcons.plus),
onPressed: onAdd,
)
Button Size#
Button.primary(
style: const ButtonStyle.primary().copyWith(
size: ButtonSize.small,
),
onPressed: onTap,
child: Text('์์ ๋ฒํผ'),
)
Precautions#
// โ WRONG: ๋น style ํ๋ผ๋ฏธํฐ ๊ธ์ง
Button.ghost(
style: const ButtonStyle.ghost(
// ๋น ๊ดํธ
),
onPressed: onTap,
child: Text('์ทจ์'),
)
// โ
CORRECT: default๊ฐ ์ฌ์ฉ ์ style ์๋ต
Button.ghost(
onPressed: onTap,
child: Text('์ทจ์'),
)
6. Forms#
Basic Form Structure#
class LoginForm extends HookWidget {
@override
Widget build(BuildContext context) {
final formKey = useMemoized(GlobalKey<FormState>.new);
final emailController = useTextEditingController();
final passwordController = useTextEditingController();
final isValid = useState(false);
void validate() {
isValid.value = formKey.currentState?.validate() ?? false;
}
return Form(
key: formKey,
onChanged: validate,
child: Column(
children: [
FormTextField(
controller: emailController,
label: Text('์ด๋ฉ์ผ'),
placeholder: '์ด๋ฉ์ผ์ ์
๋ ฅํ์ธ์',
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return '์ด๋ฉ์ผ์ ์
๋ ฅํ์ธ์';
}
if (!value.contains('@')) {
return '์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค';
}
return null;
},
),
const Gap.s4(),
FormTextField(
controller: passwordController,
label: Text('๋น๋ฐ๋ฒํธ'),
placeholder: '๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํ์ธ์',
obscureText: true,
validator: (value) {
if (value == null || value.length < 8) {
return '8์ ์ด์ ์
๋ ฅํ์ธ์';
}
return null;
},
),
const Gap.s6(),
Button.primary(
expanded: true,
enabled: isValid.value,
onPressed: () {
if (formKey.currentState!.validate()) {
// ์ ์ถ ๋ก์ง
}
},
child: Text('๋ก๊ทธ์ธ'),
),
],
),
);
}
}
FormTextField Properties#
FormTextField(
controller: controller,
label: Text('๋ผ๋ฒจ'),
placeholder: 'ํ๋ ์ด์คํ๋',
height: 56, // ๋์ด
keyboardType: TextInputType.text, // ํค๋ณด๋ ํ์
textInputAction: TextInputAction.next, // ์ก์
๋ฒํผ
autofocus: true, // ์๋ ํฌ์ปค์ค
obscureText: false, // ๋น๋ฐ๋ฒํธ ์จ๊น
maxLines: 1, // ์ต๋ ์ค ์
enabled: true, // ํ์ฑํ ์ํ
readOnly: false, // ์ฝ๊ธฐ ์ ์ฉ
validator: (value) => null, // ๊ฒ์ฆ ํจ์
onChanged: (value) {}, // After ์ฝ๋ฐฑ
onSubmitted: (value) {}, // ์ ์ถ ์ฝ๋ฐฑ
)
Validation State Visualization#
enum ValidationState { pending, valid, invalid }
Widget buildValidationIcon(ValidationState state) {
final colorScheme = context.theme.colorScheme;
return switch (state) {
ValidationState.pending => Icon(
Icons.circle_outlined,
color: colorScheme.baseContent,
),
ValidationState.valid => Icon(
Icons.check_circle,
color: colorScheme.success,
),
ValidationState.invalid => Icon(
Icons.error,
color: colorScheme.error,
),
};
}
7. Loading States#
Skeletonizer#
Converts widget to skeleton loading state.
BlocBuilder<MyBloc, MyState>(
builder: (context, state) {
return MyCard(
title: state.title ?? 'Loading Title',
subtitle: state.subtitle ?? 'Loading Subtitle',
).skeletonizer(enabled: state.isLoading);
},
)
loadingOr Extension#
Displays conditional loading widget.
// default ์ฌ์ฉ
ContentWidget().loadingOr(
isLoading: isLoading,
loadingWidget: const CircularProgressIndicator(),
)
// Mock ๋ฐ์ดํฐ๋ก ์ค์ผ๋ ํค
ContentWidget().loadingOrWithMock(
isLoading: isLoading,
mockWidget: () => ContentWidget(data: MockData()),
)
emptyOrWhen Extension#
Used for empty state handling.
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ItemCard(item: items[index]),
).emptyOrWhen(
condition: () => items.isEmpty,
emptyWidget: const EmptyStateWidget(
icon: HeroIcon(HeroIcons.inbox),
title: 'ํญ๋ชฉ์ด ์์ต๋๋ค',
subtitle: '์ ํญ๋ชฉ์ ์ถ๊ฐํด๋ณด์ธ์',
),
)
State-based UI Branching#
BlocBuilder<MyBloc, MyState>(
builder: (context, state) {
return switch (state.status) {
LoadingStatus.initial => const SizedBox.shrink(),
LoadingStatus.loading => const Center(
child: CircularProgressIndicator(),
),
LoadingStatus.success => SuccessWidget(data: state.data),
LoadingStatus.failure => ErrorWidget(
message: state.errorMessage,
onRetry: () => context.read<MyBloc>().add(const MyEvent.retried()),
),
};
},
)
8. Color System#
ColorScheme Access#
final colorScheme = context.theme.colorScheme;
Semantic Colors#
| Token | Purpose | Example |
|---|---|---|
primary | Primary actions, brand | Buttons, links |
primaryContent | Text on primary | Button text |
success | Success state | Completion, approval |
error | Error state | Failure, error |
warning | Warning state | Caution, alerts |
info | Information display | Guidance, tips |
Base Colors#
| Token | Purpose | Example |
|---|---|---|
base100 | Default background | Page background |
base200 | Card background | Cards, containers |
base300 | Divider lines, borders | Dividers |
baseContent | Default text | Titles, body text |
neutral | Neutral elements | Disabled buttons |
Usage Examples#
Container(
color: colorScheme.base100,
child: Column(
children: [
Container(
color: colorScheme.base200,
child: Text('์นด๋ ๋ด์ฉ').baseContent,
),
Divider(color: colorScheme.base300),
Container(
color: colorScheme.success,
child: Text('์ฑ๊ณต!').successContent,
),
],
),
)
Applying Opacity#
// โ
CORRECT: withValues ์ฌ์ฉ
colorScheme.baseContent.withValues(alpha: 0.5)
Colors.black.withValues(alpha: 0.2)
// โ WRONG: withOpacity ์ฌ์ฉ (deprecated)
colorScheme.baseContent.withOpacity(0.5)
9. Spacing#
Gap Widget#
// ์์ Gap
const Gap.s1() // 4px
const Gap.s2() // 8px
const Gap.s3() // 12px
const Gap.s4() // 16px
const Gap.s5() // 20px
const Gap.s6() // 24px
const Gap.s8() // 32px
// ๋์ Gap
Gap(Spacing.s4)
Spacing Constants#
// EdgeInsets์์ ์ฌ์ฉ
EdgeInsets.all(Spacing.s4)
EdgeInsets.symmetric(
horizontal: Spacing.s4,
vertical: Spacing.s2,
)
EdgeInsets.only(
top: Spacing.s4,
bottom: Spacing.s2,
left: Spacing.s4,
right: Spacing.s4,
)
Consistent Spacing Usage#
Column(
children: [
Header(),
const Gap.s4(), // ํค๋ ์๋ 16px
ContentSection(),
const Gap.s6(), // ์น์
๊ฐ 24px
Footer(),
],
)
Padding(
padding: EdgeInsets.symmetric(
horizontal: Spacing.s4, // ์ข์ฐ 16px
vertical: Spacing.s2, // ์ํ 8px
),
child: Content(),
)
10. Overlays#
Dialog#
Future<bool?> showConfirmDialog(BuildContext context) {
return showDialog<bool>(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.2),
builder: (context) => AlertDialog(
title: Text('์ญ์ ํ์ธ'),
content: Text('์ ๋ง ์ญ์ ํ์๊ฒ ์ต๋๊น?'),
actions: [
Button.ghost(
onPressed: () => Navigator.pop(context, false),
child: Text('์ทจ์'),
),
Button.primary(
onPressed: () => Navigator.pop(context, true),
child: Text('์ญ์ '),
),
],
),
);
}
// ์ฌ์ฉ
final result = await showConfirmDialog(context);
if (result == true) {
// ์ญ์ ์ํ
}
BottomSheet#
void showOptionsSheet(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true, // ๋์ด ์กฐ์ ๊ฐ๋ฅ
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Padding(
padding: EdgeInsets.all(Spacing.s4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: HeroIcon(HeroIcons.pencil),
title: Text('ํธ์ง'),
onTap: () {
Navigator.pop(context);
// ํธ์ง ๋ก์ง
},
),
ListTile(
leading: HeroIcon(HeroIcons.trash),
title: Text('์ญ์ ').error,
onTap: () {
Navigator.pop(context);
// ์ญ์ ๋ก์ง
},
),
],
),
),
),
);
}
Popover#
Popover(
positions: [PopoverPosition.bottom],
barrierColor: Colors.transparent,
builder: (context) => Container(
padding: EdgeInsets.all(Spacing.s2),
decoration: BoxDecoration(
color: colorScheme.base200,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
PopoverItem(title: '์ต์
1', onTap: onOption1),
PopoverItem(title: '์ต์
2', onTap: onOption2),
],
),
),
child: IconButton.ghost(
icon: HeroIcon(HeroIcons.ellipsisVertical),
onPressed: null, // Popover๊ฐ ์ฒ๋ฆฌ
),
)
11. Lists#
Basic ListView#
ListView.separated(
padding: EdgeInsets.all(Spacing.s4),
itemCount: items.length,
separatorBuilder: (_, __) => const Gap.s2(),
itemBuilder: (context, index) {
final item = items[index];
return ItemCard(item: item);
},
)
RefreshIndicator#
RefreshIndicator(
onRefresh: () async {
context.read<MyBloc>().add(const MyEvent.refreshed());
// BLoC ์ํ๊ฐ ์
๋ฐ์ดํธ๋ ๋๊น์ง ๋๊ธฐ
await context.read<MyBloc>().stream.firstWhere(
(state) => !state.isRefreshing,
);
},
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ItemCard(item: items[index]),
),
)
CustomScrollView + SliverList#
CustomScrollView(
slivers: [
SliverAppBar(
title: Text('๋ชฉ๋ก'),
floating: true,
),
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: Spacing.s4),
sliver: SliverList.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Gap.s2(),
itemBuilder: (context, index) => ItemCard(item: items[index]),
),
),
],
)
Infinite Scroll (Pagination)#
class InfiniteListWidget extends HookWidget {
@override
Widget build(BuildContext context) {
final scrollController = useScrollController();
useEffect(() {
void onScroll() {
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 200) {
context.read<MyBloc>().add(const MyEvent.loadMore());
}
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, []);
return BlocBuilder<MyBloc, MyState>(
builder: (context, state) {
return ListView.builder(
controller: scrollController,
itemCount: state.items.length + (state.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Center(child: CircularProgressIndicator());
}
return ItemCard(item: state.items[index]);
},
);
},
);
}
}
12. Navigation#
GoRouter Setup#
// route ์ ์
@TypedGoRoute<HomeRoute>(
path: '/',
routes: [
TypedGoRoute<ProfileRoute>(path: 'profile/:userId'),
TypedGoRoute<SettingsRoute>(path: 'settings'),
],
)
class HomeRoute extends GoRouteData {
const HomeRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const HomePage();
}
}
class ProfileRoute extends GoRouteData {
const ProfileRoute({required this.userId});
final String userId;
@override
Widget build(BuildContext context, GoRouterState state) {
return ProfilePage(userId: userId);
}
}
Navigation Methods#
// Navigate (replace history)
context.go('/home');
// Push page (keep history)
context.push('/details');
// Go back
context.pop();
// Go back with result
context.pop(result);
// TypedRoute usage (type-safe)
const ProfileRoute(userId: '123').go(context);
const ProfileRoute(userId: '123').push(context);
Receiving Navigation Results#
// Navigating page
Button.primary(
onPressed: () async {
final result = await context.push<String>('/select-item');
if (result != null) {
// ๊ฒฐ๊ณผ ์ฒ๋ฆฌ
}
},
child: Text('์ ํ'),
)
// Page returning result
Button.primary(
onPressed: () => context.pop(selectedItem),
child: Text('ํ์ธ'),
)
Deep Link Parameters#
@TypedGoRoute<SearchRoute>(path: '/search')
class SearchRoute extends GoRouteData {
const SearchRoute({this.query, this.category});
final String? query;
final String? category;
@override
Widget build(BuildContext context, GoRouterState state) {
return SearchPage(
initialQuery: query,
initialCategory: category,
);
}
}
// Navigate with query parameters
const SearchRoute(query: 'flutter', category: 'tutorial').go(context);
// โ /search?query=flutter&category=tutorial