Flutter UI Patterns - Reference#
๊ธฐ์ค: coui v0.127.15 (bare-widget, Co ์ ๋์ด ์ ๊ฑฐ ์ธ๋). ์์ธ API๋ cc-coui ํ๋ฌ๊ทธ์ธ์ ๊ฐ๋ณ ์คํฌ์ 1์ฐจ ์์ค๋ก ์ฐธ์กฐํ๋ค.
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 BlocSignalProvider(
create: (_) => MyBloc()..add(const MyEvent.started()),
child: Scaffold(
headers: [
AppBar(
title: Text('ํ์ด์ง ์ ๋ชฉ'),
leading: [
Button(
variant: .ghost,
shape: .square,
onPressed: () => context.pop(),
child: const Icon(LucideIcons.arrowLeft),
),
],
trailing: [
Button(
variant: .ghost,
shape: .square,
onPressed: onSettings,
child: const Icon(LucideIcons.settings),
),
],
),
],
child: const _Body(),
),
);
}
}
class _Body extends StatelessWidget {
const _Body();
@override
Widget build(BuildContext context) {
return BlocSignalBuilder<MyBloc, MyState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: CoreSpace.space16),
child: Column(
children: [
// ์ปจํ
์ธ
],
),
);
},
);
}
}
Pattern Selection Criteria#
| Pattern | When to Use | Example |
|---|---|---|
| HookWidget + BlocSignalProvider | Async data loading | Details Page, List |
| HookWidget only | Only local state needed | Settings Page, Form |
| StatelessWidget | No state | Static info Page |
Precautions#
// โ
CORRECT: BlocSignalProvider๋ ํ์ด์ง ๋ ๋ฒจ์์ ์์ฑ
class MyPage extends HookWidget {
@override
Widget build(BuildContext context) {
return BlocSignalProvider(
create: (_) => MyBloc(),
child: _Body(),
);
}
}
// โ WRONG: build ๋ฉ์๋ ๋ด์์ ๋งค๋ฒ ์์ฑ ๊ธ์ง
class MyPage extends HookWidget {
@override
Widget build(BuildContext context) {
final bloc = MyBloc(); // ๋งค๋ฒ ์๋ก ์์ฑ๋จ!
return BlocSignalProvider.value(
value: bloc,
child: _Body(),
);
}
}
2. State Management#
BLoC Pattern Overview#
User Action โ Event โ BLoC โ State โ UI Update
BlocSignalBuilder#
Used for UI rendering based on state.
BlocSignalBuilder<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),
};
},
)
BlocSignalConsumer#
Used when both UI rendering and side effects are needed.
BlocSignalConsumer<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,
);
},
)
BlocSignalListener#
Used when only side effects are needed.
BlocSignalListener<PaymentBloc, PaymentState>(
listenWhen: (previous, current) => current.isCompleted,
listener: (context, state) {
showDialog(
context: context,
builder: (_) => SuccessDialog(),
);
},
child: PaymentForm(),
)
MultiBlocSignalProvider#
Used in pages requiring multiple BLoCs.
MultiBlocSignalProvider(
providers: [
BlocSignalProvider(create: (_) => UserBloc()),
BlocSignalProvider(create: (_) => SettingsBloc()),
BlocSignalProvider(create: (_) => NotificationBloc()),
],
child: SettingsPage(),
)
Event Dispatching#
// โ
CORRECT: context.read ์ฌ์ฉ
Button(
onPressed: () {
context.read<MyBloc>().add(const MyEvent.submitted());
},
child: const Text('์ ์ถ'),
)
// โ WRONG: context.watch๋ก ์ด๋ฒคํธ ๋ฐ์ก ๊ธ์ง
Button(
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}'),
Input(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#
context.textStyles Pattern#
This project's standard text styling approach. context.textStyles returns the
CoUI Typography from the current theme; pick a <size><weight>
getter and pass
it to Text(..., style:).
// ํฌ๊ธฐ + ๊ตต๊ธฐ ๊ฒฐํฉ getter (์ด๋ฆ์ด ๊ณง ์คํ์ผ)
Text('ํ
์คํธ', style: context.textStyles.xsSemibold) // 12px / 600
Text('ํ
์คํธ', style: context.textStyles.smNormal) // 14px / 400
Text('ํ
์คํธ', style: context.textStyles.baseNormal) // 16px / 400
Text('ํ
์คํธ', style: context.textStyles.lgSemibold) // 18px / 600
Text('ํ
์คํธ', style: context.textStyles.xlBold) // 20px / 700
// ์์ฃผ ์ฐ๋ ์กฐํฉ
Text('์ ๋ชฉ', style: context.textStyles.lgSemibold)
Text('๋ถ์ ๋ชฉ', style: context.textStyles.baseSemibold)
Text('๋ณธ๋ฌธ', style: context.textStyles.smNormal)
Color Composition (copyWith)#
Apply color via copyWith on the style getter โ there is no color chaining.
Text(
'์ ๋ชฉ',
style: context.textStyles.lgSemibold.copyWith(
color: context.appColors.neutral5,
),
)
Text(
'์บก์
',
style: context.textStyles.smNormal.copyWith(
color: context.appColors.neutral60,
),
)
Precautions#
// โ WRONG: TextStyle ์ง์ ์ ์ ๊ธ์ง
Text('ํ
์คํธ', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))
// โ WRONG: ์ ๊ฑฐ๋ ํจํด
Text('ํ
์คํธ').lg.bold // chaining ์ ๊ฑฐ๋จ
context.theme.typography.lg // context.theme.typography ๊ธ์ง
// โ
CORRECT: context.textStyles getter ์ฌ์ฉ
Text('ํ
์คํธ', style: context.textStyles.lgSemibold)
5. Buttons#
Button Variants (Button)#
A single Button widget selects its look via the variant: enum. Pass the
variant with dot-shorthand (variant: .ghost). Default is
variant: .primary, size: .md.
// Primary - ์ฃผ์ ์ก์
(์ ์ถ, ํ์ธ) โ variant defaults to primary
Button(
onPressed: onSubmit,
expanded: true, // ์ ์ฒด ๋๋น
enabled: isValid, // ํ์ฑํ ์ํ
child: const Text('ํ์ธ'),
)
// Ghost - ๋ณด์กฐ ์ก์
(์ทจ์, ๋ค๋ก)
Button(
variant: .ghost,
onPressed: onCancel,
child: const Text('์ทจ์'),
)
// Card - ์นด๋ ํํ ๋ฒํผ
Button(
variant: .card,
onPressed: onTap,
child: const Column(
children: [
Icon(LucideIcons.settings),
Text('์ค์ '),
],
),
)
// Outline - ํ
๋๋ฆฌ ๋ฒํผ
Button(
variant: .outline,
onPressed: onTap,
child: const Text('์์ธํ ๋ณด๊ธฐ'),
)
// Destructive - ์ํ ์ก์
Button(
variant: .destructive,
onPressed: onDelete,
child: const Text('์ญ์ '),
)
CoreButtonVariant: primary ยท secondary ยท outline ยท
ghost ยท link ยท
text ยท plain ยท destructive ยท card
ยท menu ยท menubar ยท fixed
Icon-only Buttons#
There is no separate IconButton. Use Button with shape: .square
(or
.circle) and a Icon child.
Button(
variant: .ghost,
shape: .square,
onPressed: onEdit,
child: const Icon(LucideIcons.pencil),
)
Button(
shape: .circle,
onPressed: onAdd,
child: const Icon(LucideIcons.plus),
)
LucideIconsonly covers the standard icon set. For a brand/custom icon with no Lucide equivalent, use the project's generatedAssets.svg.*accessor instead โ see SKILL.md ยง 5.5 Icons.
Button Size#
Size is the size: enum (CoreComponentSize): xs ยท sm
ยท md (default) ยท
lg ยท xl. There is no ButtonSize / ButtonDensity.
Button(
size: .sm,
onPressed: onTap,
child: const Text('์์ ๋ฒํผ'),
)
Precautions#
// โ WRONG: ์ ๊ฑฐ๋ named-constructor / ButtonStyle ํจํด
Button.primary(onPressed: onTap, child: Text('ํ์ธ'))
ButtonStyle.ghost()
Button.ghost(style: const ButtonStyle.ghost(), ...)
// โ
CORRECT: Button + variant enum, chrome ์ buttonStyle ๋ก
Button(variant: .ghost, onPressed: onTap, child: const Text('์ทจ์'))
Button(
buttonStyle: CoreButtonStyle(width: 200),
onPressed: onTap,
child: const 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: [
TextFormField(
controller: emailController,
decoration: const InputDecoration(
labelText: '์ด๋ฉ์ผ',
hintText: '์ด๋ฉ์ผ์ ์
๋ ฅํ์ธ์',
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return '์ด๋ฉ์ผ์ ์
๋ ฅํ์ธ์';
}
if (!value.contains('@')) {
return '์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค';
}
return null;
},
),
const Gap.space16(),
TextFormField(
controller: passwordController,
decoration: const InputDecoration(
labelText: '๋น๋ฐ๋ฒํธ',
hintText: '๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํ์ธ์',
),
obscureText: true,
validator: (value) {
if (value == null || value.length < 8) {
return '8์ ์ด์ ์
๋ ฅํ์ธ์';
}
return null;
},
),
const Gap.space24(),
Button(
expanded: true,
enabled: isValid.value,
onPressed: () {
if (formKey.currentState!.validate()) {
// ์ ์ถ ๋ก์ง
}
},
child: const Text('๋ก๊ทธ์ธ'),
),
],
),
);
}
}
FormTextFielddoes not exist. UseInput(bare text-entry primitive) or the CoUITextFieldcomponent (labelled field with slots). For Material-form validation you may still useForm+TextFormField.
Input / TextField Properties#
// Input - bare text-entry primitive (no label / leading / trailing slots)
Input(
type: CoreInputType.text, // text/password/email/number/tel/url/search
placeholder: 'ํ๋ ์ด์คํ๋',
value: value,
enabled: true,
required: false,
minLength: 3,
maxLength: 50,
onChanged: (value) {},
onSubmitted: (value) {},
)
// ์๋ฌ variant + chrome ์ค๋ฒ๋ผ์ด๋๋ inputStyle ์ฌ๋กฏ (size:/shape: ํ๋ผ๋ฏธํฐ ์์)
Input(
type: CoreInputType.email,
placeholder: '์ด๋ฉ์ผ',
variant: CoreInputVariant.error,
inputStyle: CoreInputStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
),
)
// TextField - label / placeholder / prefix / suffix / errorText ์ฌ๋กฏ
// (TextField.placeholder is a Widget, not a String โ Input.placeholder is the String one)
TextField(
label: '์ด๋ฉ์ผ',
placeholder: const Text('์ด๋ฉ์ผ์ ์
๋ ฅํ์ธ์'),
controller: controller,
keyboardType: TextInputType.emailAddress,
prefix: const Icon(LucideIcons.mail),
errorText: emailError, // null ์ด๋ฉด ์๋ฌ ๋ฏธํ์
onChanged: (value) {},
)
Validation State Visualization#
enum ValidationState { pending, valid, invalid }
Widget buildValidationIcon(BuildContext context, ValidationState state) {
final colorScheme = context.theme.colorScheme;
return switch (state) {
ValidationState.pending => Icon(
LucideIcons.circle,
iconStyle: CoreIconStyle(color: colorScheme.onSurface.toValue()),
),
ValidationState.valid => Icon(
LucideIcons.circleCheck,
iconStyle: CoreIconStyle(color: colorScheme.success.toValue()),
),
ValidationState.invalid => Icon(
LucideIcons.circleAlert,
iconStyle: CoreIconStyle(color: colorScheme.error.toValue()),
),
};
}
7. Loading States#
Skeletonizer#
Converts widget to skeleton loading state.
BlocSignalBuilder<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: Icon(LucideIcons.inbox),
title: 'ํญ๋ชฉ์ด ์์ต๋๋ค',
subtitle: '์ ํญ๋ชฉ์ ์ถ๊ฐํด๋ณด์ธ์',
),
)
State-based UI Branching#
BlocSignalBuilder<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;
CoUI's current ColorScheme (a CoreColorScheme typedef) uses Material 3
semantic tokens โ the old daisyUI-style names below are deprecated / no
longer the recommended API. Verify the full token list against coui-theme.
Semantic Colors (M3 tokens โ current)#
| Token | Purpose | Example |
|---|---|---|
primary / onPrimary |
Primary actions, brand / text-on-primary | Buttons, links |
secondary / onSecondary |
Secondary emphasis | Secondary buttons, chips |
success | Success state | Completion, approval |
error / onError |
Error state / text-on-error | Failure, destructive actions |
warning | Warning state | Caution, alerts |
surface | Default background | Page background |
surfaceContainer |
Card / elevated-surface background | Cards, containers |
surfaceContainerHigh |
Higher-elevation background / strong border | Emphasized panels, dark dividers |
onSurface |
Default text on surface |
Titles, body text |
onSurfaceVariant | Secondary/muted text | Captions, placeholders |
outlineVariant |
Default border/divider | Dividers, hairline borders |
โ Deprecated (daisyUI-era, do not use in new code)#
primaryContent, base100, base200, base300, baseContent,
successContent,
and the Text('...').baseContent / .successContent extension-chaining style โ
none of these are the current API. Use the M3 tokens above instead, applied via
style: TextStyle(color: colorScheme.onSurface) / context.appColors.*, not
extension chaining on Text.
Usage Examples#
Container(
color: colorScheme.surface,
child: Column(
children: [
Container(
color: colorScheme.surfaceContainer,
child: Text('์นด๋ ๋ด์ฉ', style: TextStyle(color: colorScheme.onSurface)),
),
Divider(color: colorScheme.outlineVariant),
Container(
color: colorScheme.success,
child: const Text('์ฑ๊ณต!'),
),
],
),
)
Applying Opacity#
// โ
CORRECT: withValues ์ฌ์ฉ
colorScheme.onSurface.withValues(alpha: 0.5)
Colors.black.withValues(alpha: 0.2)
// โ WRONG: withOpacity ์ฌ์ฉ (deprecated)
colorScheme.onSurface.withOpacity(0.5)
9. Spacing#
Gap Widget#
Gap.spaceN() token constructors โ the token name equals its pixel value.
For non-token sizes / cross extent / fill color use gapStyle: CoreGapStyle(...).
const Gap.space4() // 4px
const Gap.space8() // 8px
const Gap.space12() // 12px
const Gap.space16() // 16px
const Gap.space20() // 20px
const Gap.space24() // 24px
const Gap.space32() // 32px
// ๋นํ ํฐ ํฌ๊ธฐ / cross extent / fill color
const Gap(gapStyle: CoreGapStyle(size: 18))
Removed/legacy:
Gap.s1(),Gap(Spacing.s4),Spacing.s*,Insets.*,Gap(size: CoreSpace.spaceN)(v0.92 โ useGap.spaceN()).
CoreSpace in EdgeInsets#
const EdgeInsets.all(CoreSpace.space16)
const EdgeInsets.symmetric(
horizontal: CoreSpace.space16,
vertical: CoreSpace.space8,
)
const EdgeInsets.only(
top: CoreSpace.space16,
bottom: CoreSpace.space8,
left: CoreSpace.space16,
right: CoreSpace.space16,
)
Consistent Spacing Usage#
Column(
children: [
Header(),
const Gap.space16(), // ํค๋ ์๋ 16px
ContentSection(),
const Gap.space24(), // ์น์
๊ฐ 24px
Footer(),
],
)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: CoreSpace.space16, // ์ข์ฐ 16px
vertical: CoreSpace.space8, // ์ํ 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: const Text('์ญ์ ํ์ธ'),
content: const Text('์ ๋ง ์ญ์ ํ์๊ฒ ์ต๋๊น?'),
actions: [
Button(
variant: .ghost,
onPressed: () => Navigator.pop(context, false),
child: const Text('์ทจ์'),
),
Button(
variant: .destructive,
onPressed: () => Navigator.pop(context, true),
child: const 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: const EdgeInsets.all(CoreSpace.space16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.pencil),
title: const Text('ํธ์ง'),
onTap: () {
Navigator.pop(context);
// ํธ์ง ๋ก์ง
},
),
ListTile(
leading: const Icon(LucideIcons.trash),
title: Text(
'์ญ์ ',
style: context.textStyles.baseNormal.copyWith(
color: context.theme.colorScheme.error,
),
),
onTap: () {
Navigator.pop(context);
// ์ญ์ ๋ก์ง
},
),
],
),
),
),
);
}
Popover#
Popover(
positions: [PopoverPosition.bottom],
barrierColor: Colors.transparent,
builder: (context) => Container(
padding: const EdgeInsets.all(CoreSpace.space8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainer,
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: Button(
variant: .ghost,
shape: .square,
onPressed: null, // Popover๊ฐ ์ฒ๋ฆฌ
child: const Icon(LucideIcons.ellipsisVertical),
),
)
11. Lists#
Basic ListView#
ListView.separated(
padding: const EdgeInsets.all(CoreSpace.space16),
itemCount: items.length,
separatorBuilder: (_, __) => const Gap.space8(),
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: const EdgeInsets.symmetric(horizontal: CoreSpace.space16),
sliver: SliverList.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Gap.space8(),
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 BlocSignalBuilder<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(
onPressed: () async {
final result = await context.push<String>('/select-item');
if (result != null) {
// ๊ฒฐ๊ณผ ์ฒ๋ฆฌ
}
},
child: const Text('์ ํ'),
)
// Page returning result
Button(
onPressed: () => context.pop(selectedItem),
child: const 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