flutter-widget-ui

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Flutter 위젯 UI 패턴

Flutter 组件UI模式

핵심 원칙

核心原则

UI는 바보다. Screen 위젯은
state
onAction
을 받아 렌더링과 사용자 인터랙션만 담당한다. 비즈니스 로직, 데이터 변환, 네트워크 호출은 전부 ViewModel/UseCase/Data 레이어에서 끝난다. Screen 안에는 로직이 없어야 테스트와 디자인 리뷰가 쉬워진다.

UI仅负责视图展示。Screen组件仅接收
state
onAction
,承担渲染与用户交互的职责。业务逻辑、数据转换、网络请求全部交由ViewModel/UseCase/Data层处理。Screen内部不应包含任何逻辑,这样才能让测试与设计评审更简单。

StatelessWidget을 기본으로

以StatelessWidget为基础

이 프로젝트의 거의 모든 화면은
StatelessWidget
이다. 상태는 ViewModel(
ChangeNotifier
)이 보관하고
ListenableBuilder
가 구독한다.
StatefulWidget
은 다음 경우에만 쓴다:
  • 위젯 생애주기 안에서만 의미 있는 로컬 UI 상태 (
    TextEditingController
    ,
    ScrollController
    ,
    TabController
    , 애니메이션 컨트롤러).
  • initState
    /
    dispose
    가 꼭 필요한 구독·리소스 초기화.
앱 전반 상태는 전부 ViewModel 로 옮긴다.

本项目中几乎所有页面都使用
StatelessWidget
。状态由ViewModel(
ChangeNotifier
)存储,并通过
ListenableBuilder
订阅。仅在以下场景使用
StatefulWidget
  • 仅在组件生命周期内有意义的本地UI状态(
    TextEditingController
    ScrollController
    TabController
    、动画控制器)。
  • 必须通过
    initState
    /
    dispose
    完成的订阅或资源初始化。
应用全局状态全部转移至ViewModel中。

const
를 최대한 활용하라

最大化利用
const

생성자 호출 앞의
const
는 리빌드 시 동일 인스턴스가 재사용되게 해 준다. 정적 위젯, 리터럴 스타일,
SizedBox(height: N)
등 거의 모든 정적 노드에
const
를 붙인다.
dart
const SizedBox(height: 10),
const ChefProfile(),
const Icon(Icons.share, size: 20),
하위 위젯이
const
생성자를 갖도록 작성해 재사용 경로를 열어준다.

构造函数前添加
const
可让组件在重建时复用相同实例。静态组件、字面量样式、
SizedBox(height: N)
等几乎所有静态节点都应添加
const
dart
const SizedBox(height: 10),
const ChefProfile(),
const Icon(Icons.share, size: 20),
编写子组件时需实现
const
构造函数,为复用创造条件。

ViewModel 구독 — ListenableBuilder

ViewModel订阅——ListenableBuilder

Root 위젯에서 ViewModel 변화를 구독할 때의 표준 패턴은
ListenableBuilder
.
builder
안에서만
viewModel.state
를 읽어 불필요한 리빌드 범위를 줄인다.
dart
return ListenableBuilder(
  listenable: viewModel,
  builder: (context, _) {
    final state = viewModel.state;
    if (state.isLoading) return const Center(child: CircularProgressIndicator());
    return IngredientScreen(state: state, onAction: viewModel.onAction);
  },
);
Screen 은
ListenableBuilder
를 몰라야 한다. 그 책임은 Root.

根组件中订阅ViewModel变化的标准模式是
ListenableBuilder
。仅在
builder
内部读取
viewModel.state
,以此缩小不必要的重建范围。
dart
return ListenableBuilder(
  listenable: viewModel,
  builder: (context, _) {
    final state = viewModel.state;
    if (state.isLoading) return const Center(child: CircularProgressIndicator());
    return IngredientScreen(state: state, onAction: viewModel.onAction);
  },
);
Screen组件无需知晓
ListenableBuilder
的存在,该职责由根组件承担。

리스트 — ListView.builder

列表——ListView.builder

항목 수가 작더라도 고정 리스트는
Column
대신
ListView.builder
를 써서 확장에 대비한다.
dart
ListView.builder(
  itemCount: state.ingredients.length,
  itemBuilder: (context, index) {
    return Column(
      children: [
        IngredientItem(ingredient: state.ingredients[index]),
        const SizedBox(height: 10),
      ],
    );
  },
)
큰 리스트이거나 키가 필요한 경우
itemBuilder
가 반환하는 위젯에
Key(item.id.toString())
를 넣어 재배치/삭제 시 상태가 섞이지 않게 한다.
탭 간 전환에서 상태를 보존하고 싶다면
IndexedStack
이 깔끔하다:
dart
IndexedStack(
  index: state.selectedTabIndex,
  children: [IngredientList(state: state), ProcedureList(state: state)],
)

即使条目数量较少,固定列表也应使用
ListView.builder
而非
Column
,为后续扩展做准备。
dart
ListView.builder(
  itemCount: state.ingredients.length,
  itemBuilder: (context, index) {
    return Column(
      children: [
        IngredientItem(ingredient: state.ingredients[index]),
        const SizedBox(height: 10),
      ],
    );
  },
)
若为大型列表或需要键值的场景,需在
itemBuilder
返回的组件中添加
Key(item.id.toString())
,避免重排/删除时状态混乱。
标签页切换时如需保留状态,
IndexedStack
是简洁的选择:
dart
IndexedStack(
  index: state.selectedTabIndex,
  children: [IngredientList(state: state), ProcedureList(state: state)],
)

공용 컴포넌트 재사용

公共组件复用

이 프로젝트는
lib/core/presentation/components/
에 디자인 시스템 격 위젯이 모여 있다. 새 화면에서 비슷한 버튼/카드/입력이 필요하면 먼저 이 디렉터리를 뒤진다. 예:
  • BigButton
    ,
    MediumButton
    ,
    SmallButton
  • SearchInputField
    ,
    InputField
  • FilterButton
    ,
    FilterButtons
    ,
    RatingButton
  • RecipeCard
    ,
    NewRecipeCard
    ,
    DishCard
    ,
    IngredientRecipeCard
    ,
    RecipeGridItem
  • TwoTab
    ,
    ChefProfile
    ,
    IngredientItem
    ,
    ProcedureItem
없으면 같은 디렉터리에 새 컴포넌트를 추가한다. 두 번 이상 쓸 일이 있는 UI만 공용으로 올리고, 한 화면 전용이라면 해당 feature 폴더에 보조 위젯으로 둔다.
디자인 토큰은
lib/ui/color_styles.dart
,
lib/ui/text_styles.dart
에 있으므로 raw 색/폰트를 쓰지 않고 토큰을 재사용한다.
dart
Text('1 serve', style: TextStyles.smallerTextRegular.copyWith(color: ColorStyles.gray3)),

本项目的类设计系统组件集中在
lib/core/presentation/components/
目录下。当新页面需要类似按钮/卡片/输入框时,先查看该目录。例如:
  • BigButton
    ,
    MediumButton
    ,
    SmallButton
  • SearchInputField
    ,
    InputField
  • FilterButton
    ,
    FilterButtons
    ,
    RatingButton
  • RecipeCard
    ,
    NewRecipeCard
    ,
    DishCard
    ,
    IngredientRecipeCard
    ,
    RecipeGridItem
  • TwoTab
    ,
    ChefProfile
    ,
    IngredientItem
    ,
    ProcedureItem
若没有匹配组件,则在同一目录下新增组件。仅将使用两次以上的UI设为公共组件,若为单页面专用,则放在对应feature目录下作为辅助组件。
设计令牌位于
lib/ui/color_styles.dart
lib/ui/text_styles.dart
中,请勿直接使用原始颜色/字体,需复用令牌。
dart
Text('1 serve', style: TextStyles.smallerTextRegular.copyWith(color: ColorStyles.gray3)),

다이얼로그와 스낵바

对话框与Snackbar

다이얼로그/스낵바는
BuildContext
가 필요하므로 Root 위젯의 인터랙션 콜백에서 띄운다. ViewModel 은 "어떤 메뉴가 선택됐다"는 Action 만 알면 된다.
dart
// ingredient_root.dart
IngredientScreen(
  state: viewModel.state,
  onAction: viewModel.onAction,
  onTapMenu: (menu) {
    switch (menu) {
      case IngredientMenu.share:
        showDialog(
          context: context,
          builder: (_) => ShareDialog(
            link: 'app.Recipe.co/jollof_rice',
            onTapCopyLink: (link) {
              viewModel.onAction(IngredientAction.onTapShareMenu(link));
              Navigator.pop(context);
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(content: Text('Link Copied', textAlign: TextAlign.center)),
              );
            },
          ),
        );
      // ...
    }
  },
),
요령: 다이얼로그 닫기와 상태 반영을 같이 해야 한다면 콜백 안에서
viewModel.onAction(...)
Navigator.pop(context)
→ 후속 UI 표시 순서로.

对话框/Snackbar需要
BuildContext
,因此需在根组件的交互回调中触发。ViewModel仅需知晓「某个菜单被选中」的Action即可。
dart
// ingredient_root.dart
IngredientScreen(
  state: viewModel.state,
  onAction: viewModel.onAction,
  onTapMenu: (menu) {
    switch (menu) {
      case IngredientMenu.share:
        showDialog(
          context: context,
          builder: (_) => ShareDialog(
            link: 'app.Recipe.co/jollof_rice',
            onTapCopyLink: (link) {
              viewModel.onAction(IngredientAction.onTapShareMenu(link));
              Navigator.pop(context);
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(content: Text('Link Copied', textAlign: TextAlign.center)),
              );
            },
          ),
        );
      // ...
    }
  },
),
技巧:若需同时关闭对话框并更新状态,需按「
viewModel.onAction(...)
Navigator.pop(context)
→ 后续UI展示」的顺序执行回调。

Scaffold 구성 관례

Scaffold配置惯例

  • Scaffold
    AppBar
    SafeArea
    Padding(horizontal: 30)
    Column
    이 이 프로젝트의 기본 골격 (
    ingredient_screen.dart
    참고). 수평 패딩을
    SafeArea
    안쪽에 두어 노치 회피와 일관 여백을 동시에 얻는다.
  • AppBar
    actions
    PopupMenuButton
    으로 메뉴를 꽂고, 각
    PopupMenuItem
    onTap
    에서 Screen이 받은 콜백(
    onTapMenu
    )을 호출한다.

  • Scaffold
    AppBar
    SafeArea
    Padding(horizontal: 30)
    Column
    是本项目的基础骨架(参考
    ingredient_screen.dart
    )。将水平内边距放在
    SafeArea
    内部,同时实现刘海避让与统一边距。
  • AppBar
    actions
    使用
    PopupMenuButton
    嵌入菜单,每个
    PopupMenuItem
    onTap
    中调用Screen接收的回调(
    onTapMenu
    )。

텍스트 필드

文本字段

TextField
/
TextFormField
값은 로컬 컨트롤러 + ViewModel Action 로 이중화한다. 사용자 입력마다 Action 을 디스패치해 상태에 기록한다. 지속이 필요 없는 순수 로컬이라면
StatefulWidget
+
TextEditingController
만으로도 충분하다.
dart
TextField(
  onChanged: (value) => onAction(SearchAction.onQueryChange(value)),
)

TextField
/
TextFormField
的值采用本地控制器 + ViewModel Action双重处理。用户每次输入时触发Action记录状态。若为无需持久化的纯本地状态,仅使用
StatefulWidget
+
TextEditingController
即可。
dart
TextField(
  onChanged: (value) => onAction(SearchAction.onQueryChange(value)),
)

접근성

可访问性

  • 의미 있는 이미지/아이콘에는
    Semantics(label: '...')
    또는
    IconButton(tooltip: '...')
    을 달아 스크린리더 사용자를 배려한다.
  • 텍스트 크기 변경에 대비해 고정
    height
    대신
    Padding
    +
    mainAxisSize
    /
    Expanded
    로 레이아웃을 구성한다.
  • 터치 타깃은 최소 48×48 논리 픽셀. 작은 아이콘 버튼은
    IconButton
    이나
    InkWell
    + 충분한 padding 으로 감싼다.

  • 有意义的图片/图标需添加
    Semantics(label: '...')
    IconButton(tooltip: '...')
    ,照顾屏幕阅读器用户。
  • 为应对文本大小变化,需使用
    Padding
    +
    mainAxisSize
    /
    Expanded
    构建布局,而非固定
    height
  • 触摸目标最小为48×48逻辑像素。小图标按钮需用
    IconButton
    InkWell
    包裹并添加足够内边距。

성능 요령

性能技巧

  • const
    생성자 적극 사용.
  • 리스트 아이템에 무거운 계산이 있으면
    itemBuilder
    밖에서 미리 수행 (예: 포매팅은 UI 모델에 이미 반영).
  • 애니메이션은
    AnimatedBuilder
    /
    TweenAnimationBuilder
    /
    ImplicitlyAnimatedWidget
    으로 리빌드 범위를 좁힌다. 프레임마다 부모 Scaffold 를 재빌드하지 않도록 애니메이션 영역을 작은 위젯으로 분리.
  • 큰 이미지는
    cacheWidth
    /
    cacheHeight
    로 디코딩 크기를 제한한다.

  • 积极使用
    const
    构造函数。
  • 若列表条目包含复杂计算,需在
    itemBuilder
    外提前完成(例如:格式化操作已在UI模型中完成)。
  • 动画使用
    AnimatedBuilder
    /
    TweenAnimationBuilder
    /
    ImplicitlyAnimatedWidget
    缩小重建范围。将动画区域拆分为小组件,避免每帧重建父级Scaffold。
  • 大图片需通过
    cacheWidth
    /
    cacheHeight
    限制解码尺寸。

체크리스트 — 새 Screen 위젯

检查清单——新Screen组件

  • StatelessWidget
    으로 선언하고
    state
    ,
    onAction
    만 파라미터로 받는다
  • Root 에서 전달된 네비게이션/다이얼로그 콜백이 있다면 추가 파라미터로 받는다
  • 정적 자식은 전부
    const
  • 리스트는
    ListView.builder
    , 탭 전환은
    IndexedStack
    검토
  • 공용 컴포넌트(
    core/presentation/components
    )를 먼저 사용, 없으면 추가
  • 색/폰트는
    ColorStyles
    ,
    TextStyles
    사용
  • BuildContext
    가 필요한 효과(다이얼로그·스낵바·내비게이션)는 Root 에서 처리

  • 声明为
    StatelessWidget
    ,仅接收
    state
    onAction
    作为参数
  • 若存在根组件传递的导航/对话框回调,需作为额外参数接收
  • 静态子组件全部添加
    const
  • 列表使用
    ListView.builder
    ,标签页切换考虑使用
    IndexedStack
  • 优先使用公共组件(
    core/presentation/components
    ),无匹配则新增
  • 颜色/字体使用
    ColorStyles
    TextStyles
  • 需要
    BuildContext
    的效果(对话框·Snackbar·导航)由根组件处理

안티 패턴

反模式

  • ❌ Screen 안에서
    getIt<...>()
    호출 → Root 가 주입해야 한다.
  • ❌ Screen 안에서
    context.push(...)
    직접 호출 → 콜백으로 위임.
  • setState
    로 앱 상태를 관리 → ViewModel 로 올려라.
  • ❌ 매직 넘버 색상/폰트 직접 사용 →
    ColorStyles
    /
    TextStyles
    를 쓴다.
  • Column
    + 수동 스크롤 — 항목이 많아질 수 있으면
    ListView.builder
    .
  • const
    생략으로 동일 위젯이 매 프레임 재생성.
  • ❌ 在Screen内部调用
    getIt<...>()
    → 应由根组件注入。
  • ❌ 在Screen内部直接调用
    context.push(...)
    → 需委托给回调。
  • ❌ 使用
    setState
    管理应用状态 → 需转移至ViewModel。
  • ❌ 直接使用魔法值颜色/字体 → 需使用
    ColorStyles
    /
    TextStyles
  • Column
    + 手动滚动 — 若条目可能增多,需使用
    ListView.builder
  • ❌ 省略
    const
    导致相同组件每帧重建。