iT邦幫忙

2025 iThome 鐵人賽

DAY 3
0
Mobile Development

我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅系列 第 3

Day 3 - 元件的藝術:打造類型安全、高效能的 Flutter UI 分子

  • 分享至 

  • xImage
  •  

大家好,歡迎來到第三天的旅程!在 Day2,我們定義了設計系統中最基礎的「原子」—顏色與字體排印。今天,我們要沿著「原子設計」的階梯向上,開始組合這些原子,創造出更具意義的
「分子 (Molecules)」

但今天我們不只談論基礎的按鈕和輸入框。作為一位 Flutter 工程師,我們要探討的是 元件的藝術—如何運用Flutter 的進階特性,打造出既美觀又高效的 UI 分子。我們將深入探討:

  • CustomPainter 的藝術:如何從 Figma 設計稿精確重建複雜的 SVG 路徑
  • 狀態機模式:如何設計優雅的元件狀態管理
  • 類型安全的設計系統:如何用列舉和抽象類別打造可擴展的元件架構
  • 效能優化:如何避免不必要的重繪和重建

這些技術不僅能讓你的元件更加精緻,更能體現出你對 Flutter 深度理解。

實作案例一:類型安全的按鈕系統設計

我們第一個要探討的,是如何設計一個類型安全、可擴展的按鈕系統。在我們的專案中,這個系統實作在
lib/common/widgets/primary_button_widget.dartbutton_types.dart,展現了清晰的架構思維。

思考點:為什麼需要類型安全的設計系統?

在大型專案中,我們經常會遇到這樣的問題:

  • 設計師說「用主要顏色」,但工程師不知道是哪個 Color
  • 產品經理說「改成大尺寸」,但工程師不知道是哪個 double
  • 測試時發現按鈕樣式不一致,但很難追蹤問題來源?

解決方案:列舉驅動的設計系統

// lib/common/widgets/button_types.dart

/// 按鈕顏色主題 - 語義化的顏色選擇
enum ButtonColor {
  primary,    // 主要操作
  secondary,  // 輔助操作
  neutral,    // 一般操作
  disable,    // 禁用狀態
  warning,    // 警告操作
}

/// 按鈕尺寸 - 基於設計系統的標準尺寸
enum ButtonSize {
  small,   // 緊湊佈局
  medium,  // 一般佈局
  large,   // 重要操作
}

/// 按鈕樣式 - 視覺層次的漸進式設計
enum ButtonLevel {
  filled,  // 強調主要操作
  outline, // 次要操作
  text,    // 最輕量的操作
}

/// 按鈕狀態 - 支援狀態機模式
enum ButtonStatus {
  default_, // 正常狀態
  hover,    // 懸停狀態
}

💡 小知識:為什麼是 default_ 而不是 default

眼尖的讀者可能會注意到我們用了 default_ 而非 default。這是因為 default 是 Dart 語言中的一個保留關鍵字,主要用於
switch 語句中。為了避免命名衝突,同時在語義上表達「預設」的含義,在變數或列舉值後方加上底線 _
是一種非常普遍且優雅的開發慣例。

這種設計的優勢:

  1. 編譯時檢查:錯誤在編譯時就會被發現
  2. IDE 支援:自動完成和重構支援
  3. 語義化:程式碼自解釋,不需要註解
  4. 可擴展:新增變體只需要修改列舉和對應的處理邏輯

動手時間:實作 PrimaryButton

思考完 API 設計後,我們就可以在 lib/common/widgets/ 下建立 primary_button_widget.dart
了。我們的實作比基礎版本更加完整,支援多種變體和狀態:

// lib/common/widgets/primary_button_widget.dart

// (imports omitted)

class PrimaryButtonWidget extends StatelessWidget {
  final String text;
  final VoidCallback? onPressed;
  final bool isLoading;
  final ButtonColor color;
  final ButtonSize size;
  final ButtonLevel level;
  final ButtonStatus status;
  final bool showIcon;
  final Widget? icon;
  final double? width;
  final double? height;

  const PrimaryButtonWidget({
    super.key,
    required this.text,
    this.onPressed,
    this.isLoading = false,
    this.color = ButtonColor.primary,
    this.size = ButtonSize.large,
    this.level = ButtonLevel.filled,
    this.status = ButtonStatus.default_,
    this.showIcon = true,
    this.icon,
    this.width,
    this.height,
  });

  @override
  Widget build(BuildContext context) {
    final bool isDisabled = _calculateDisabledState();
    final ButtonColor effectiveColor = _getEffectiveColor(isDisabled);
    final ButtonStatus effectiveStatus = _getEffectiveStatus(isDisabled);

    return SizedBox(
      width: width,
      height: height,
      child: _buildButton(effectiveColor, effectiveStatus, isDisabled),
    );
  }

  bool _calculateDisabledState() {
    return isLoading || onPressed == null;
  }

  ButtonColor _getEffectiveColor(bool isDisabled) {
    return isDisabled ? ButtonColor.disable : color;
  }

  ButtonStatus _getEffectiveStatus(bool isDisabled) {
    return isDisabled ? ButtonStatus.default_ : status;
  }

  Widget _buildButton(ButtonColor color, ButtonStatus status, bool isDisabled) {
    switch (level) {
      case ButtonLevel.filled:
        return _buildFilledButton(color, status, isDisabled);
      case ButtonLevel.outline:
        return _buildOutlineButton(color, status, isDisabled);
      case ButtonLevel.text:
        return _buildTextButton(color, status, isDisabled);
    }
  }

  // 其他輔助方法...
}

實作案例二:CustomPainter 的藝術 - 從 Figma 到程式碼

作為一位 Flutter 工程師,我們經常需要將設計師的創意精確地轉化為程式碼。當遇到複雜的 SVG 圖形時,
CustomPainter 是我們的最佳武器。

在我們的專案中,我們有一個吉祥物 Cubi 的對話框,它需要精確地重現 Figma 設計稿的每一個細節。讓我們看看如何用CustomPainter 實現這個挑戰:

思考點:為什麼選擇 CustomPainter 而不是 SVG?

  1. 效能優勢:CustomPainter 直接繪製到 Canvas,比 SVG 解析更快
  2. 精確控制:可以精確控制每個像素的繪製
  3. 動態性:可以根據狀態動態調整繪製內容
  4. 記憶體效率:不需要載入額外的 SVG 檔案

動手時間:實作 CubiSpeechBubbleWidget

// lib/common/widgets/cubi_speech_bubble_widget.dart

class CubiSpeechBubbleWidget extends StatelessWidget {
  final String text;
  final Color? backgroundColor;
  final Color? borderColor;

  const CubiSpeechBubbleWidget({
    super.key,
    required this.text,
    this.backgroundColor,
    this.borderColor,
  });

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: CubiSpeechBubblePainter(
        backgroundColor: backgroundColor ?? AppColors.orange200,
        borderColor: borderColor ?? AppColors.actionPrimary,
      ),
      child: Container(
        padding: EdgeInsets.only(
          top: AppSpacing.s,    // 12px
          bottom: AppSpacing.l, // 24px
          left: AppSpacing.xs,  // 8px
          right: AppSpacing.xs, // 8px
        ),
        child: Text(
          text,
          textAlign: TextAlign.center,
          style: AppTypography.heading4,
        ),
      ),
    );
  }
}

核心技術:精確的 Path 重建

class CubiSpeechBubblePainter extends CustomPainter {
  final Color backgroundColor;
  final Color borderColor;

  @override
  void paint(Canvas canvas, Size size) {
    // 基於 Figma Union SVG (211x48) 的單一路徑重建
    final double scaleX = size.width / 211.0;
    final double scaleY = size.height / 48.0;

    final double bodyHeight = 39.0 * scaleY; // 矩形高度 (不含尾巴)
    final double r = 4.0 * (scaleX < scaleY ? scaleX : scaleY); // 4px 圓角

    // 尾巴座標(基於原始 SVG)
    final double tailLeftX = 99.8047 * scaleX;
    final double tailRightX = 110.195 * scaleX;
    final double tailTipX = 105.0 * scaleX;
    final double tailTipY = 48.0 * scaleY;

    final Path path = Path();

    // 從頂部左圓角開始,順時針繪製外輪廓
    path.moveTo(r, 0);
    path.lineTo(size.width - r, 0);
    path.arcToPoint(Offset(size.width, r), radius: Radius.circular(r));
    path.lineTo(size.width, bodyHeight - r);
    path.arcToPoint(Offset(size.width - r, bodyHeight), radius: Radius.circular(r));
    
    // 底邊:到尾巴右側起點
    path.lineTo(tailRightX, bodyHeight);
    // 尾巴:右 -> 尖 -> 左
    path.lineTo(tailTipX, tailTipY);
    path.lineTo(tailLeftX, bodyHeight);
    // 底邊:從尾巴左側到左下角前
    path.lineTo(r, bodyHeight);
    path.arcToPoint(Offset(0, bodyHeight - r), radius: Radius.circular(r));
    path.lineTo(0, r);
    path.arcToPoint(Offset(r, 0), radius: Radius.circular(r));
    path.close();

    // 填色
    final Paint fillPaint = Paint()
      ..color = backgroundColor
      ..style = PaintingStyle.fill;
    canvas.drawPath(path, fillPaint);

    // 邊框(單一路徑描邊,不會有交界線)
    if (borderColor != backgroundColor) {
      final Paint strokePaint = Paint()
        ..color = borderColor
        ..style = PaintingStyle.stroke
        ..strokeWidth = 1.0;
      canvas.drawPath(path, strokePaint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

關鍵技術要點:

  1. 等比縮放:確保在不同尺寸下保持正確比例
  2. 單一路徑:避免矩形與尾巴交界處的額外描邊
  3. 精確座標:直接從 Figma 設計稿提取座標
  4. 效能優化shouldRepaint 返回 false 避免不必要的重繪

深度思考:此實作的響應式設計 (RWD) 考量

眼尖的讀者可能會發現,我們目前的 CubiSpeechBubblePainter 實作是基於 Figma 提供的固定寬高比 (211x48)
進行等比縮放的。這種做法的優點是能快速且精確地還原設計稿,但在面對不同寬高比的容器時,可能會導致圖形不成比例地拉伸。

一個更健壯、更具響應性的做法是:不依賴任何固定的原始尺寸,而是將圓角、尾巴高度等定義為固定值,並讓所有繪製座標都動態地根據傳入的
size 參數來計算。

那為什麼我們在這裡選擇了前一種做法呢?

主要是出於教學目的。本文的重點在於演示「如何將設計師提供的複雜 SVG Path 座標,精確地轉譯為 Flutter 中的
Path 物件」這一核心技巧。基於固定比例的縮放,最能直接體現這一轉換過程。

在理解了這個基礎之後,讀者們可以挑戰自己,嘗試實現一個完全響應式的版本,那將會是對 CustomPainter
更深層次的掌握!

實作案例三:狀態機驅動的智能輸入框設計

下一個我們要探討的是如何用狀態機模式設計一個智能的輸入框。在我們的專案中,這個元件實作在
lib/common/widgets/custom_input_field_widget.dart,展現了狀態機的強大威力。

思考點:為什麼要客製化輸入框,而不直接使用 TextFormField?

在開始實作前,我們必須回答一個問題:為什麼不直接在每個頁面使用 TextFormField 並客製化其InputDecoration
呢?

答案在於一致性、可維護性與邏輯封裝。我們客製化 CustomInputFieldWidget 的目的主要有三點:

  1. 統一全 App 的設計系統:我們希望 App 中所有的輸入框都有完全相同的視覺風格、動畫和互動行為。透過建立一個共用元件,我們可以確保設計的一致性,避免在
    20 個不同的地方寫出 20 種微小差異的 InputDecoration。

  2. 封裝複雜的狀態邏輯:一個輸入框的視覺狀態(如邊框顏色)可能取決於多個條件:是否聚焦(isFocused)
    、是否有內容 (hasText)、是否有誤 (hasError) 等。如果用傳統的 if-else 來處理,邏輯會變得非常混亂。我們的
    CustomInputFieldWidget將這些複雜的判斷全部封裝在一個清晰的「狀態機」(InputFieldStatus)
    內部,讓元件的狀態轉換變得極其乾淨且可預測。這正是本節的重點。

  3. 減少重複的樣板程式碼 (Boilerplate)
    :每次都手動設定InputDecoration非常繁瑣。將其封裝後,我們只需要使用
    ,就能得到一個功能齊全、樣式統一的輸入框,大幅提升了開發效率。

思考點:為什麼需要狀態機?

傳統的輸入框設計通常用簡單的 boolean 來控制狀態,但這會導致:

  • 狀態組合爆炸:isFocused && hasError && isTyping 等複雜條件
  • 難以維護:新增狀態需要修改多處邏輯
  • 容易出錯:狀態轉換沒有明確的規則

解決方案:狀態機模式

/// 輸入框狀態列舉 - 定義所有可能的狀態
enum InputFieldStatus { 
  default_,  // 預設狀態
  focus,     // 聚焦狀態
  typing,    // 輸入中狀態
  typed,     // 已輸入狀態
  error,     // 錯誤狀態
  overLimit  // 超出限制狀態
}

輸入框狀態機圖

以下是我們的 CustomInputFieldWidget 狀態機的視覺化表示:

狀態轉換說明:

觸發條件 從狀態 到狀態 視覺變化
點擊輸入框 default_ focus 邊框變藍色
開始輸入 focus typing 保持藍色邊框
失去焦點(有內容) typing typed 邊框變回灰色
失去焦點(無內容) focus default_ 邊框變回灰色
清空內容 typed default_ 回到初始狀態
外部錯誤 任何狀態 error 邊框變紅色
字數超限 typing overLimit 邊框變橙色

核心技術:智能狀態計算

class _CustomInputFieldState extends State<CustomInputFieldWidget> {
  late FocusNode _focusNode;
  bool _isFocused = false;

  /// 獲取當前狀態 - 狀態機的核心邏輯
  InputFieldStatus get _currentStatus {
    // 優先使用外部傳入的狀態(用於錯誤顯示等)
    if (widget.status != InputFieldStatus.default_) {
      return widget.status;
    }

    // 根據內部狀態智能判斷
    if (_isFocused && widget.controller.text.isNotEmpty) {
      return InputFieldStatus.typing;
    }
    if (_isFocused) {
      return InputFieldStatus.focus;
    }
    if (widget.controller.text.isNotEmpty) {
      return InputFieldStatus.typed;
    }
    if (widget.maxLength != null && 
        widget.controller.text.length > widget.maxLength!) {
      return InputFieldStatus.overLimit;
    }
    return InputFieldStatus.default_;
  }

  /// 根據狀態獲取邊框顏色 - 狀態驅動的樣式
  Color get _borderColor {
    switch (_currentStatus) {
      case InputFieldStatus.focus:
      case InputFieldStatus.typing:
        return AppColors.primary;  // 聚焦時使用主色
      case InputFieldStatus.typed:
        return AppColors.surfaceBorder;  // 已輸入時使用邊框色
      case InputFieldStatus.error:
        return AppColors.error;  // 錯誤時使用錯誤色
      case InputFieldStatus.overLimit:
        return AppColors.warning;  // 超出限制時使用警告色
      case InputFieldStatus.default_:
        return AppColors.surfaceBorder;  // 預設狀態
    }
  }
}

狀態機的優勢:

  1. 明確的狀態轉換:每個狀態都有明確的進入和退出條件
  2. 易於測試:可以針對每個狀態編寫單元測試
  3. 可擴展性:新增狀態只需要修改列舉和對應的處理邏輯
  4. 可預測性:狀態轉換遵循明確的規則,不會出現意外狀態

動手時間:實作 CustomInputField

lib/features/activity/presentation/widgets/ 下建立 custom_input_field_widget.dart
。我們的實作比基礎版本更加完整,支援多種狀態和樣式:

// lib/features/activity/presentation/widgets/custom_input_field_widget.dart

// (imports omitted)

/// 輸入框狀態列舉
enum InputFieldStatus { default_, focus, typing, typed, error, overLimit }

class CustomInputFieldWidget extends StatefulWidget {
  final TextEditingController controller;
  final String? hintText;
  final String? labelText;
  final bool required;
  final int? maxLength;
  final int maxLines;
  final bool obscureText;
  final TextInputType? keyboardType;
  final TextInputAction? textInputAction;
  final String? Function(String?)? validator;
  final void Function(String)? onChanged;
  final void Function(String)? onSubmitted;
  final VoidCallback? onTap;
  final bool enabled;
  final bool readOnly;
  final bool autofocus;
  final TextCapitalization textCapitalization;
  final List<TextInputFormatter>? inputFormatters;
  final Widget? suffixIcon;
  final Widget? prefixIcon;
  final bool showCharacterCount;
  final FocusNode? focusNode;
  final InputFieldStatus status;
  final String? errorText;
  final String? helperText;

  const CustomInputFieldWidget({
    super.key,
    required this.controller,
    this.hintText,
    this.labelText,
    this.required = false,
    this.maxLength,
    this.maxLines = 1,
    this.obscureText = false,
    this.keyboardType,
    this.textInputAction,
    this.validator,
    this.onChanged,
    this.onSubmitted,
    this.onTap,
    this.enabled = true,
    this.readOnly = false,
    this.autofocus = false,
    this.textCapitalization = TextCapitalization.none,
    this.inputFormatters,
    this.suffixIcon,
    this.prefixIcon,
    this.showCharacterCount = false,
    this.focusNode,
    this.status = InputFieldStatus.default_,
    this.errorText,
    this.helperText,
  });

  @override
  State<CustomInputFieldWidget> createState() => _CustomInputFieldState();
}

class _CustomInputFieldState extends State<CustomInputFieldWidget> {
  late FocusNode _focusNode;
  bool _isFocused = false;

  @override
  void initState() {
    super.initState();
    _focusNode = widget.focusNode ?? FocusNode();
    _focusNode.addListener(_onFocusChanged);
  }

  @override
  void dispose() {
    if (widget.focusNode == null) {
      _focusNode.dispose();
    }
    super.dispose();
  }

  void _onFocusChanged() {
    setState(() {
      _isFocused = _focusNode.hasFocus;
    });
  }

  InputFieldStatus get _currentStatus {
    if (widget.status != InputFieldStatus.default_) {
      return widget.status;
    }
    if (_isFocused && widget.controller.text.isNotEmpty) {
      return InputFieldStatus.typing;
    }
    if (_isFocused) {
      return InputFieldStatus.focus;
    }
    if (widget.controller.text.isNotEmpty) {
      return InputFieldStatus.typed;
    }
    return InputFieldStatus.default_;
  }

  Color get _borderColor {
    switch (_currentStatus) {
      case InputFieldStatus.focus:
        return AppColors.primary;
      case InputFieldStatus.typing:
        return AppColors.primary;
      case InputFieldStatus.typed:
        return AppColors.surfaceBorder;
      case InputFieldStatus.error:
        return AppColors.error;
      case InputFieldStatus.overLimit:
        return AppColors.warning;
      case InputFieldStatus.default_:
        return AppColors.surfaceBorder;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        if (widget.labelText != null) ...[
          Row(
            children: [
              if (widget.required)
                Text(
                  '*',
                  style: AppTypography.body2.copyWith(
                    color: AppColors.textAccent,
                  ),
                ),
              Text(
                widget.labelText!,
                style: AppTypography.body2.copyWith(
                  color: AppColors.textPrimary,
                ),
              ),
            ],
          ),
          SizedBox(height: AppSpacing.xs),
        ],
        Container(
          decoration: BoxDecoration(
            color: AppColors.surfaceWhite,
            borderRadius: BorderRadius.circular(4),
            border: Border.all(color: _borderColor, width: 1),
          ),
          child: TextFormField(
            controller: widget.controller,
            focusNode: _focusNode,
            maxLength: widget.maxLength,
            maxLines: widget.maxLines,
            obscureText: widget.obscureText,
            keyboardType: widget.keyboardType,
            textInputAction: widget.textInputAction,
            validator: widget.validator,
            onChanged: widget.onChanged,
            onFieldSubmitted: widget.onSubmitted,
            onTap: widget.onTap,
            enabled: widget.enabled,
            readOnly: widget.readOnly,
            autofocus: widget.autofocus,
            textCapitalization: widget.textCapitalization,
            inputFormatters: widget.inputFormatters,
            style: AppTypography.body1.copyWith(
              color: widget.enabled
                  ? AppColors.textPrimary
                  : AppColors.textTertiary,
            ),
            decoration: InputDecoration(
              hintText: widget.hintText,
              hintStyle: AppTypography.body1.copyWith(
                color: AppColors.textTertiary,
              ),
              prefixIcon: widget.prefixIcon,
              suffixIcon: widget.suffixIcon,
              border: InputBorder.none,
              enabledBorder: InputBorder.none,
              focusedBorder: InputBorder.none,
              errorBorder: InputBorder.none,
              focusedErrorBorder: InputBorder.none,
              disabledBorder: InputBorder.none,
              filled: false,
              contentPadding: EdgeInsets.symmetric(
                horizontal: AppSpacing.m,
                vertical: AppSpacing.s,
              ),
              counterText: '',
            ),
          ),
        ),
        if (widget.showCharacterCount && widget.maxLength != null) ...[
          SizedBox(height: AppSpacing.xxs),
          Align(
            alignment: Alignment.centerRight,
            child: Text(
              '${widget.controller.text.length}/${widget.maxLength}',
              style: AppTypography.caption.copyWith(
                color: AppColors.textTertiary,
              ),
            ),
          ),
        ],
      ],
    );
  }
}

結語與下一步

今天,我們深入探討了 Flutter 元件的藝術,從基礎的按鈕設計到進階的 CustomPainter
實作,從狀態機模式到效能優化技巧。這些技術不僅能讓你的元件更加精緻,更能體現出你對 Flutter 的深度理解。

技術要點總結:

  • 類型安全:用列舉驅動的設計系統確保編譯時檢查
  • CustomPainter:精確重現 Figma 設計稿的複雜圖形
  • 狀態機:用明確的狀態轉換規則管理複雜的 UI 狀態
  • 效能優化:從設計階段就考慮效能問題

把東西做成「分子」:可複用、可組合、可維護

與其關注元件應該放在哪個資料夾,更重要的是把元件設計成「分子 (Molecules)」:

  • 封裝單一明確的職責,輸入與輸出清晰
  • 樣式、互動、狀態邏輯一致且可預測
  • 能在不同頁面、不同情境被組合重用

當元件具備「分子」的特性時,它自然能在專案中正確落位:真正通用者可被多處重用,特定情境者則作為更大「有機體」的一部分被使用。重點是設計品質,而非目錄位置。

現在,我們的「樂高」零件箱裡已經有了幾個高品質的積木。明天,我們將把焦點轉向多國語系與本地化
,讓這些元件在不同語言與文化情境下依然保持一致、易讀且好用。

在 Day 4,我們會實作完整的國際化基礎設施:包含 flutter_localizationsintl 的整合、ARB
翻譯資源管理、動態語言切換,以及日期與數字等常見格式化的最佳實踐,並說明如何讓 UI 元件在多語系下自然排版與顯示。

期待與您在 Day 4 相見!


📋 相關資源

📝 專案資訊

  • 專案名稱: Crew Up!
  • 開發日誌: Day 3 - 元件的藝術:打造類型安全、高效能的 Flutter UI 分子
  • 文章日期: 2025-09-17
  • 技術棧: Flutter 3.8+, Dart 3.8+, Riverpod 2.6+, Clean Architecture

上一篇
Day 2 - 設計系統的哲學與實作
下一篇
Day 4 - 國際化與在地化:打造全球化的 App
系列文
我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言