LogoSkills

flutter-ui Reference

Provides detailed descriptions, example code, and precautions for each Use Case.

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#

PatternWhen to UseExample
HookWidget + BlocProviderAsync data loadingDetails Page, List
HookWidget onlyOnly local state neededSettings Page, Form
StatelessWidgetNo stateStatic 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#

TokenPurposeExample
primaryPrimary actions, brandButtons, links
primaryContentText on primaryButton text
successSuccess stateCompletion, approval
errorError stateFailure, error
warningWarning stateCaution, alerts
infoInformation displayGuidance, tips

Base Colors#

TokenPurposeExample
base100Default backgroundPage background
base200Card backgroundCards, containers
base300Divider lines, bordersDividers
baseContentDefault textTitles, body text
neutralNeutral elementsDisabled 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);
  }
}
// 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('ํ™•์ธ'),
)
@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