flutter學習第 15 節(jié):自定義 Widget 與組件封裝

在 Flutter 開發(fā)中,隨著應用規(guī)模的擴大,合理封裝自定義 Widget 和可復用組件變得至關(guān)重要。良好的組件設(shè)計可以顯著提高代碼復用率、降低維護成本,并保證 UI 風格的一致性。本節(jié)課將深入探討自定義 Widget 的設(shè)計原則、實現(xiàn)方法以及組件間的數(shù)據(jù)傳遞機制。

一、封裝可復用組件的原則

設(shè)計高質(zhì)量的可復用組件需要遵循一些核心原則,這些原則有助于創(chuàng)建出靈活、健壯且易于維護的組件:

1. 單一職責原則

每個組件應專注于完成單一功能,避免設(shè)計 "萬能組件"。例如,一個按鈕組件不應同時處理表單驗證邏輯,一個列表組件不應包含具體的列表項渲染邏輯。
反例:一個既負責網(wǎng)絡(luò)請求又負責 UI 展示的組件
正例:將網(wǎng)絡(luò)請求與 UI 展示分離,UI 組件只負責展示數(shù)據(jù)

2. 高內(nèi)聚低耦合

  • 高內(nèi)聚:組件內(nèi)部相關(guān)功能應緊密結(jié)合,形成一個有機整體
  • 低耦合:組件之間應通過明確定義的接口進行通信,減少直接依賴

3. 配置靈活性

通過參數(shù)配置讓組件適應不同場景,但需平衡靈活性與復雜性:

  • 提供合理的默認值,減少使用成本
  • 關(guān)鍵屬性應可配置,次要屬性可固定
  • 使用 bool、enum 等類型限制配置選項,避免錯誤使用

4. 可擴展性

設(shè)計時預留擴展點,便于未來功能擴展:

  • 通過 childbuilder 參數(shù)允許自定義子組件
  • 使用繼承或組合方式擴展基礎(chǔ)組件功能
  • 避免硬編碼業(yè)務(wù)邏輯

5. 一致性與可識別性

  • 保持組件風格與應用整體設(shè)計一致
  • 組件行為應符合用戶預期(如按鈕點擊反饋)
  • 相似功能的組件應保持 API 設(shè)計的一致性

6. 可測試性

  • 組件應易于實例化和測試
  • 避免在組件內(nèi)部創(chuàng)建全局狀態(tài)
  • 關(guān)鍵邏輯應可獨立測試


二、自定義 Widget 基礎(chǔ)

Flutter 中自定義組件主要有兩種方式:組合現(xiàn)有 Widget自定義 RenderObject。對于大多數(shù)場景,我們應優(yōu)先選擇組合方式,因為它更簡單且能滿足大部分需求。

1. StatelessWidget 與 StatefulWidget 的選擇

  • StatelessWidget:適用于無狀態(tài)或狀態(tài)由父組件管理的場景,如靜態(tài)展示組件、純 UI 組件
  • StatefulWidget:適用于有內(nèi)部狀態(tài)管理的組件,如計數(shù)器、展開 / 折疊面板

選擇建議:盡量使用 StatelessWidget,將狀態(tài)提升到父組件或狀態(tài)管理框架中,可使組件更易于測試和復用。

2. 組件命名規(guī)范

  • 使用 PascalCase(帕斯卡命名法),如 PrimaryButton、UserProfileCard
  • 名稱應準確描述組件功能,避免過于抽象或籠統(tǒng)
  • 對于具有相似基礎(chǔ)功能但樣式不同的組件,可使用一致的前綴,如 PrimaryButton、SecondaryButton


三、自定義 Widget 示例

1. 示例一:帶加載狀態(tài)的按鈕(StatefulWidget)

這個按鈕組件將支持加載狀態(tài)、禁用狀態(tài)、自定義樣式等功能,適用于表單提交、數(shù)據(jù)請求等場景。

import 'package:flutter/material.dart';

/// 帶加載狀態(tài)的按鈕組件
/// 支持正常、加載、禁用三種狀態(tài)
/// 可自定義顏色、圓角、文本樣式等
class LoadingButton extends StatefulWidget {
  /// 按鈕文本
  final String text;

  /// 點擊回調(diào)
  final VoidCallback? onPressed;

  /// 是否處于加載狀態(tài)
  final bool isLoading;

  /// 按鈕主色調(diào)
  final Color? color;

  /// 文本顏色
  final Color textColor;

  /// 禁用狀態(tài)顏色
  final Color disabledColor;

  /// 禁用狀態(tài)文本顏色
  final Color disabledTextColor;

  /// 按鈕圓角
  final double borderRadius;

  /// 按鈕內(nèi)邊距
  final EdgeInsetsGeometry padding;

  /// 加載指示器顏色
  final Color indicatorColor;

  LoadingButton({
    super.key,
    required this.text,
    this.onPressed,
    this.isLoading = false,
    this.color,
    this.textColor = Colors.white,
    this.disabledColor = Colors.grey,
    this.disabledTextColor = Colors.grey,
    this.borderRadius = 8.0,
    this.padding = const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
    this.indicatorColor = Colors.white,
  }) : assert(text.isNotEmpty, "按鈕文本不能為空");

  @override
  State<LoadingButton> createState() => _LoadingButtonState();
}

class _LoadingButtonState extends State<LoadingButton> {
  // 按鈕是否可點擊
  bool get _isEnabled => widget.onPressed != null && !widget.isLoading;

  @override
  Widget build(BuildContext context) {
    // 獲取主題中的主色調(diào)作為默認顏色
    final theme = Theme.of(context);
    final buttonColor = widget.color ?? theme.primaryColor;

    return ElevatedButton(
      onPressed: _isEnabled ? widget.onPressed : null,
      style: ElevatedButton.styleFrom(
        backgroundColor: _getButtonColor(buttonColor),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(widget.borderRadius),
        ),
        padding: widget.padding,
        disabledBackgroundColor: widget.disabledColor,
      ),
      child: _buildButtonContent(buttonColor),
    );
  }

  // 根據(jù)狀態(tài)獲取按鈕顏色
  Color _getButtonColor(Color defaultColor) {
    if (widget.isLoading) {
      return defaultColor.withOpacity(0.8);
    }
    return defaultColor;
  }

  // 構(gòu)建按鈕內(nèi)容(文本或加載指示器)
  Widget _buildButtonContent(Color buttonColor) {
    if (widget.isLoading) {
      return Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          SizedBox(
            width: 20,
            height: 20,
            child: CircularProgressIndicator(
              strokeWidth: 2,
              valueColor: AlwaysStoppedAnimation<Color>(widget.indicatorColor),
            ),
          ),
          const SizedBox(width: 8),
          Text(
            "加載中...",
            style: TextStyle(
              color: _isEnabled ? widget.textColor : widget.disabledTextColor,
            ),
          ),
        ],
      );
    }

    return Text(
      widget.text,
      style: TextStyle(
        color: _isEnabled ? widget.textColor : widget.disabledTextColor,
      ),
    );
  }
}

// 使用示例
class LoadingButtonDemo extends StatefulWidget {
  const LoadingButtonDemo({super.key});

  @override
  State<LoadingButtonDemo> createState() => _LoadingButtonDemoState();
}

class _LoadingButtonDemoState extends State<LoadingButtonDemo> {
  bool _isLoading = false;

  void _handlePress() async {
    // 模擬網(wǎng)絡(luò)請求
    setState(() => _isLoading = true);
    await Future.delayed(const Duration(seconds: 2));
    setState(() => _isLoading = false);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Loading Button Demo')),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          children: [
            LoadingButton(
              text: "提交",
              onPressed: _handlePress,
              isLoading: _isLoading,
            ),
            const SizedBox(height: 20),
            LoadingButton(
              text: "禁用狀態(tài)",
              onPressed: null, // onPressed為null時按鈕禁用
              color: Colors.grey,
            ),
            const SizedBox(height: 20),
            LoadingButton(
              text: "自定義樣式",
              onPressed: () {},
              color: Colors.purple,
              borderRadius: 20,
              padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
            ),
          ],
        ),
      ),
    );
  }
}

2. 示例二:通用標題欄(StatelessWidget)

實現(xiàn)一個可高度定制的標題欄組件,支持左右按鈕、標題樣式自定義、背景色設(shè)置等功能。

import 'package:flutter/material.dart';

/// 通用標題欄組件
/// 支持自定義標題、左右按鈕、背景色等
class CommonAppBar extends StatelessWidget implements PreferredSizeWidget {
  /// 標題
  final String title;

  /// 標題組件,優(yōu)先級高于title
  final Widget? titleWidget;

  /// 左側(cè)按鈕
  final Widget? leading;

  /// 右側(cè)按鈕列表
  final List<Widget>? actions;

  /// 背景色
  final Color? backgroundColor;

  /// 標題樣式
  final TextStyle? titleStyle;

  /// 陰影高度
  final double elevation;

  /// 標題是否居中
  final bool centerTitle;

  /// 左側(cè)按鈕點擊回調(diào)(僅當未自定義leading時有效)
  final VoidCallback? onLeadingPressed;

  const CommonAppBar({
    super.key,
    this.title = "",
    this.titleWidget,
    this.leading,
    this.actions,
    this.backgroundColor,
    this.titleStyle,
    this.elevation = 4.0,
    this.centerTitle = true,
    this.onLeadingPressed,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return AppBar(
      title: titleWidget ??
          Text(
            title,
            style: titleStyle ??
                TextStyle(
                  color: theme.appBarTheme.titleTextStyle?.color ?? Colors.white,
                  fontSize: 18,
                  fontWeight: FontWeight.bold,
                ),
          ),
      leading: leading ?? _buildDefaultLeading(context),
      actions: actions,
      backgroundColor: backgroundColor ?? theme.appBarTheme.backgroundColor,
      elevation: elevation,
      centerTitle: centerTitle,
    );
  }

  // 構(gòu)建默認左側(cè)按鈕(返回按鈕)
  Widget? _buildDefaultLeading(BuildContext context) {
    // 如果是導航棧的第一個頁面,不顯示返回按鈕
    if (Navigator.canPop(context)) {
      return IconButton(
        icon: const Icon(Icons.arrow_back),
        onPressed: onLeadingPressed ?? () => Navigator.pop(context),
      );
    }
    return null;
  }

  // 定義標題欄高度,使用默認的56.0
  @override
  Size get preferredSize => const Size.fromHeight(56.0);
}

// 使用示例
class CommonAppBarDemo extends StatelessWidget {
  const CommonAppBarDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: CommonAppBar(
        title: "首頁",
        actions: [
          IconButton(
            icon: const Icon(Icons.search),
            onPressed: () => print("搜索"),
          ),
          IconButton(
            icon: const Icon(Icons.more_vert),
            onPressed: () => print("更多"),
          ),
        ],
      ),
      body: const Center(
        child: Text("頁面內(nèi)容"),
      ),
    );
  }
}

// 自定義標題樣式示例
class StyledAppBarDemo extends StatelessWidget {
  const StyledAppBarDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: CommonAppBar(
        titleWidget: const Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.star, color: Colors.yellow),
            SizedBox(width: 8),
            Text("自定義標題"),
          ],
        ),
        backgroundColor: Colors.purple,
        elevation: 0,
        leading: IconButton(
          icon: const Icon(Icons.menu),
          onPressed: () => print("菜單"),
        ),
      ),
      body: const Center(
        child: Text("帶自定義標題的頁面"),
      ),
    );
  }
}


四、組件參數(shù)校驗與默認值設(shè)置

良好的參數(shù)設(shè)計是組件易用性的關(guān)鍵,合理的默認值可以減少使用成本,而嚴格的參數(shù)校驗可以提前發(fā)現(xiàn)錯誤。

1. 必填參數(shù)與可選參數(shù)

使用 required 關(guān)鍵字標記必填參數(shù),讓編譯器幫助我們檢查參數(shù)是否完整:

class CustomButton extends StatelessWidget {
  // 必填參數(shù)
  final String text;
  final VoidCallback onPressed;

  // 可選參數(shù)
  final Color? color;

  // 使用required標記必填參數(shù)
  const CustomButton({
    super.key,
    required this.text,
    required this.onPressed,
    this.color,
  });

  // ...
}

2. 默認值設(shè)置

為可選參數(shù)提供合理的默認值,降低組件使用復雜度:

class CustomButton extends StatelessWidget {
  final String text;
  final VoidCallback onPressed;
  final Color color;
  final double borderRadius;
  final EdgeInsets padding;
  
  const CustomButton({
    super.key,
    required this.text,
    required this.onPressed,
    // 提供默認值
    this.color = Colors.blue,
    this.borderRadius = 8.0,
    this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  });
  
  // ...
}

3. 參數(shù)校驗

使用 assert 語句在開發(fā)階段進行參數(shù)校驗,提前發(fā)現(xiàn)錯誤:

class Avatar extends StatelessWidget {
  final String url;
  final double radius;
  
  const Avatar({
    super.key,
    required this.url,
    this.radius = 24.0,
  }) : 
    // 校驗radius必須為正數(shù)
    assert(radius > 0, "radius必須大于0"),
    // 校驗url不為空
    assert(url.isNotEmpty, "url不能為空");
  
  @override
  Widget build(BuildContext context) {
    return ClipOval(
      child: Image.network(
        url,
        width: radius * 2,
        height: radius * 2,
        fit: BoxFit.cover,
      ),
    );
  }
}

4. 高級參數(shù)校驗

對于復雜的參數(shù)校驗邏輯,可以在 initStatebuild 方法中進行:

class RangeSelector extends StatefulWidget {
  final double min;
  final double max;
  final double value;
  
  const RangeSelector({
    super.key,
    required this.min,
    required this.max,
    required this.value,
  });
  
  @override
  State<RangeSelector> createState() => _RangeSelectorState();
}

class _RangeSelectorState extends State<RangeSelector> {
  @override
  void initState() {
    super.initState();
    _validateParams();
  }
  
  // 復雜參數(shù)校驗
  void _validateParams() {
    if (widget.min >= widget.max) {
      throw FlutterError("min必須小于max: min=${widget.min}, max=${widget.max}");
    }
    
    if (widget.value < widget.min || widget.value > widget.max) {
      throw FlutterError("value必須在[min, max]范圍內(nèi): value=${widget.value}, min=${widget.min}, max=${widget.max}");
    }
  }
  
  // ...
}


五、使用 InheritedWidget 實現(xiàn)數(shù)據(jù)跨層傳遞

在復雜應用中,組件嵌套層級可能很深,通過構(gòu)造函數(shù)逐層傳遞數(shù)據(jù)會非常繁瑣。InheritedWidget 提供了一種高效的跨層數(shù)據(jù)傳遞方式,允許子組件直接訪問上層數(shù)據(jù)。

1. InheritedWidget 基礎(chǔ)

InheritedWidget 是一種特殊的 Widget,它可以在 Widget 樹中向下傳遞數(shù)據(jù),子組件可以通過 BuildContext 訪問這些數(shù)據(jù)。

// 1. 創(chuàng)建自定義InheritedWidget
class ThemeData {
  final Color primaryColor;
  final Color secondaryColor;
  final TextStyle textStyle;

  ThemeData({
    required this.primaryColor,
    required this.secondaryColor,
    required this.textStyle,
  });
}

class AppTheme extends InheritedWidget {
  final ThemeData data;

  const AppTheme({
    super.key,
    required this.data,
    required super.child,
  });

  // 提供靜態(tài)方法方便子組件獲取
  static AppTheme? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<AppTheme>();
  }

  // 當數(shù)據(jù)變化時,是否通知依賴的子組件重建
  @override
  bool updateShouldNotify(AppTheme oldWidget) {
    return data.primaryColor != oldWidget.data.primaryColor ||
        data.secondaryColor != oldWidget.data.secondaryColor ||
        data.textStyle != oldWidget.data.textStyle;
  }
}

2. 在 Widget 樹中使用

// 2. 在Widget樹頂層提供數(shù)據(jù)
class MyApp extends StatelessWidget {
  const MyApp({super.key});
  
  @override
  Widget build(BuildContext context) {
    // 創(chuàng)建主題數(shù)據(jù)
    final themeData = ThemeData(
      primaryColor: Colors.blue,
      secondaryColor: Colors.green,
      textStyle: const TextStyle(
        color: Colors.black87,
        fontSize: 16,
      ),
    );
    
    // 提供InheritedWidget
    return AppTheme(
      data: themeData,
      child: const MaterialApp(
        home: HomePage(),
      ),
    );
  }
}

// 3. 深層子組件訪問數(shù)據(jù)
class DeepNestedWidget extends StatelessWidget {
  const DeepNestedWidget({super.key});
  
  @override
  Widget build(BuildContext context) {
    // 獲取主題數(shù)據(jù)
    final appTheme = AppTheme.of(context);
    
    if (appTheme == null) {
      return const Text("未找到主題數(shù)據(jù)");
    }
    
    return Container(
      color: appTheme.data.secondaryColor,
      padding: const EdgeInsets.all(16),
      child: Text(
        "使用主題樣式的文本",
        style: appTheme.data.textStyle,
      ),
    );
  }
}

// 中間組件(無需傳遞數(shù)據(jù))
class HomePage extends StatelessWidget {
  const HomePage({super.key});
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("InheritedWidget示例")),
      body: const Center(
        child: IntermediateWidget(),
      ),
    );
  }
}

class IntermediateWidget extends StatelessWidget {
  const IntermediateWidget({super.key});
  
  @override
  Widget build(BuildContext context) {
    return const DeepNestedWidget();
  }
}

3. 動態(tài)更新 InheritedWidget 數(shù)據(jù)

InheritedWidget 本身是不可變的,要實現(xiàn)數(shù)據(jù)動態(tài)更新,需要結(jié)合 StatefulWidget

class ThemeProvider extends StatefulWidget {
  final Widget child;
  
  const ThemeProvider({
    super.key,
    required this.child,
  });
  
  // 提供靜態(tài)方法獲取狀態(tài)
  static _ThemeProviderState of(BuildContext context) {
    return context.findAncestorStateOfType<_ThemeProviderState>()!;
  }
  
  @override
  State<ThemeProvider> createState() => _ThemeProviderState();
}

class _ThemeProviderState extends State<ThemeProvider> {
  late ThemeData _themeData;
  
  @override
  void initState() {
    super.initState();
    // 初始化主題數(shù)據(jù)
    _themeData = ThemeData(
      primaryColor: Colors.blue,
      secondaryColor: Colors.green,
      textStyle: const TextStyle(
        color: Colors.black87,
        fontSize: 16,
      ),
    );
  }
  
  // 切換主題的方法
  void toggleTheme() {
    setState(() {
      _themeData = _themeData.primaryColor == Colors.blue
          ? ThemeData(
              primaryColor: Colors.purple,
              secondaryColor: Colors.orange,
              textStyle: const TextStyle(
                color: Colors.white,
                fontSize: 16,
              ),
            )
          : ThemeData(
              primaryColor: Colors.blue,
              secondaryColor: Colors.green,
              textStyle: const TextStyle(
                color: Colors.black87,
                fontSize: 16,
              ),
            );
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return AppTheme(
      data: _themeData,
      child: widget.child,
    );
  }
}

// 使用動態(tài)主題
class ThemedButton extends StatelessWidget {
  const ThemedButton({super.key});
  
  @override
  Widget build(BuildContext context) {
    final appTheme = AppTheme.of(context);
    
    return ElevatedButton(
      onPressed: () {
        // 切換主題
        ThemeProvider.of(context).toggleTheme();
      },
      style: ElevatedButton.styleFrom(
        backgroundColor: appTheme?.data.primaryColor,
      ),
      child: Text(
        "切換主題",
        style: TextStyle(
          color: appTheme?.data.textStyle.color,
        ),
      ),
    );
  }
}

4. InheritedWidget 注意事項

  • 性能考量updateShouldNotify 方法應準確判斷數(shù)據(jù)是否真的發(fā)生變化,避免不必要的重建
  • 不要過度使用:簡單場景下,通過構(gòu)造函數(shù)傳遞數(shù)據(jù)更直接
  • 數(shù)據(jù)類型InheritedWidget 適合傳遞全局配置、主題、用戶信息等跨組件共享的數(shù)據(jù)
  • 依賴管理:使用 dependOnInheritedWidgetOfExactType 會建立依賴關(guān)系,數(shù)據(jù)變化時會觸發(fā)重建;使用 getInheritedWidgetOfExactType 則不會建立依賴關(guān)系


六、組件通信方式

在復雜應用中,組件之間需要進行通信,F(xiàn)lutter 提供了多種組件通信方式:

1. 父子組件通信

  • 父傳子:通過構(gòu)造函數(shù)參數(shù)傳遞
  • 子傳父:通過回調(diào)函數(shù)傳遞
class ParentWidget extends StatefulWidget {
  const ParentWidget({super.key});

  @override
  State<ParentWidget> createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  String _message = "等待消息...";

  // 接收子組件消息的回調(diào)
  void _onMessageReceived(String message) {
    setState(() {
      _message = message;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text("收到的消息: $_message"),
        ChildWidget(
          // 父組件向子組件傳遞數(shù)據(jù)
          initialMessage: "你好,子組件",
          // 父組件提供回調(diào)給子組件
          onSendMessage: _onMessageReceived,
        ),
      ],
    );
  }
}

class ChildWidget extends StatelessWidget {
  final String initialMessage;
  final ValueChanged<String> onSendMessage;

  const ChildWidget({
    super.key,
    required this.initialMessage,
    required this.onSendMessage,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text("收到的初始消息: $initialMessage"),
        ElevatedButton(
          onPressed: () {
            // 子組件通過回調(diào)向父組件發(fā)送消息
            onSendMessage("你好,父組件!我是子組件");
          },
          child: const Text("發(fā)送消息給父組件"),
        ),
      ],
    );
  }
}

2. 跨級組件通信

除了 InheritedWidget,還可以使用事件總線實現(xiàn)跨級組件通信:

// 事件總線實現(xiàn)
class EventBus {
  // 單例模式
  static final EventBus _instance = EventBus._internal();
  factory EventBus() => _instance;
  EventBus._internal();

  // 存儲事件訂閱者
  final Map<Type, List<Function>> _eventListeners = {};

  // 訂閱事件
  void on<T>(void Function(T) listener) {
    if (!_eventListeners.containsKey(T)) {
      _eventListeners[T] = [];
    }
    _eventListeners[T]!.add(listener);
  }

  // 取消訂閱
  void off<T>(void Function(T) listener) {
    if (_eventListeners.containsKey(T)) {
      _eventListeners[T]!.remove(listener);
      if (_eventListeners[T]!.isEmpty) {
        _eventListeners.remove(T);
      }
    }
  }

  // 發(fā)送事件
  void emit<T>(T event) {
    if (_eventListeners.containsKey(T)) {
      // 復制一份列表再遍歷,避免在遍歷中修改列表
      List<Function> listeners = List.from(_eventListeners[T]!);
      for (var listener in listeners) {
        listener(event);
      }
    }
  }
}

// 定義事件類型
class UserLoginEvent {
  final String username;
  UserLoginEvent(this.username);
}

class UserLogoutEvent {
  UserLogoutEvent();
}

// 發(fā)送事件的組件
class LoginButton extends StatelessWidget {
  const LoginButton({super.key});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        // 發(fā)送登錄事件
        EventBus().emit(UserLoginEvent("張三"));
      },
      child: const Text("登錄"),
    );
  }
}

// 接收事件的組件
class UserStatusWidget extends StatefulWidget {
  const UserStatusWidget({super.key});

  @override
  State<UserStatusWidget> createState() => _UserStatusWidgetState();
}

class _UserStatusWidgetState extends State<UserStatusWidget> {
  String _status = "未登錄";

  @override
  void initState() {
    super.initState();
    // 訂閱登錄事件
    EventBus().on<UserLoginEvent>((event) {
      setState(() {
        _status = "已登錄:${event.username}";
      });
    });

    // 訂閱登出事件
    EventBus().on<UserLogoutEvent>((event) {
      setState(() {
        _status = "未登錄";
      });
    });
  }

  @override
  void dispose() {
    // 取消訂閱,避免內(nèi)存泄漏
    EventBus().off<UserLoginEvent>((event) {});
    EventBus().off<UserLogoutEvent>((event) {});
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text("用戶狀態(tài):$_status");
  }
}

3. 全局狀態(tài)管理

對于大型應用,推薦使用專門的狀態(tài)管理方案,如:

  • Provider
  • Bloc/Cubit
  • Riverpod
  • GetX

這些方案在 InheritedWidget 基礎(chǔ)上提供了更完善的狀態(tài)管理能力,包括狀態(tài)變更通知、依賴注入、生命周期管理等。



七、組件封裝實戰(zhàn):表單組件庫

下面我們將綜合運用本節(jié)課所學知識,封裝一套實用的表單組件庫,包括輸入框、選擇器、表單驗證等功能。

import 'package:flutter/material.dart';

// 1. 表單字段模型
class FormFieldData {
  final String id;
  dynamic value;
  String? errorMessage;
  bool touched;

  FormFieldData({
    required this.id,
    this.value,
    this.errorMessage,
    this.touched = false,
  });
}

// 2. 表單狀態(tài)管理(使用InheritedWidget)
class FormProvider extends InheritedWidget {
  final Map<String, FormFieldData> _fields = {};
  final void Function() onFormChanged;

  FormProvider({
    super.key,
    required super.child,
    required this.onFormChanged,
  });

  static FormProvider? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<FormProvider>();
  }

  // 獲取字段值
  dynamic getValue(String id) {
    return _fields[id]?.value;
  }

  // 設(shè)置字段值
  void setValue(String id, dynamic value, {bool validate = true}) {
    if (!_fields.containsKey(id)) {
      _fields[id] = FormFieldData(id: id);
    }
    _fields[id]!.value = value;
    _fields[id]!.touched = true;

    if (validate) {
      validateField(id);
    }

    onFormChanged();
  }

  // 驗證字段
  bool validateField(String id) {
    // 實際應用中會有更復雜的驗證邏輯
    // 這里簡化處理
    final field = _fields[id];
    if (field == null) return true;

    if (field.value == null || field.value.toString().isEmpty) {
      field.errorMessage = "此字段不能為空";
      return false;
    }

    field.errorMessage = null;
    return true;
  }

  // 驗證整個表單
  bool validate() {
    bool isValid = true;
    for (var field in _fields.values) {
      field.touched = true;
      if (!validateField(field.id)) {
        isValid = false;
      }
    }
    onFormChanged();
    return isValid;
  }

  // 獲取字段錯誤信息
  String? getError(String id) {
    return _fields[id]?.errorMessage;
  }

  // 檢查字段是否被觸摸過
  bool isTouched(String id) {
    return _fields[id]?.touched ?? false;
  }

  @override
  bool updateShouldNotify(FormProvider oldWidget) {
    return true;
  }
}

// 3. 表單組件
class FormContainer extends StatefulWidget {
  final Widget child;
  final void Function(bool isValid) onValidationChanged;
  final void Function(Map<String, dynamic> values) onSubmit;

  const FormContainer({
    super.key,
    required this.child,
    required this.onValidationChanged,
    required this.onSubmit,
  });

  @override
  State<FormContainer> createState() => _FormContainerState();
}

class _FormContainerState extends State<FormContainer> {
  bool _isValid = false;

  void _onFormChanged(BuildContext context) {
    final formProvider = FormProvider.of(context);
    if (formProvider != null) {
      final isValid = formProvider.validate();
      setState(() {
        _isValid = isValid;
      });
      widget.onValidationChanged(isValid);
    }
  }

  void _handleSubmit(BuildContext context) {
    final formProvider = FormProvider.of(context);
    if (formProvider != null && formProvider.validate()) {
      // 收集表單數(shù)據(jù)
      final values = <String, dynamic>{};
      formProvider._fields.forEach((key, value) {
        values[key] = value.value;
      });
      widget.onSubmit(values);
    }
  }

  @override
  Widget build(BuildContext context) {
    return FormProvider(
      onFormChanged: () => _onFormChanged(context),
      child: Column(
        children: [
          widget.child,
          const SizedBox(height: 20),
          LoadingButton(
            text: "提交",
            onPressed: _isValid ? () => _handleSubmit(context) : null,
          ),
        ],
      ),
    );
  }
}

// 4. 自定義輸入框組件
class FormInputField extends StatelessWidget {
  final String id;
  final String label;
  final String hintText;
  final TextInputType keyboardType;
  final bool obscureText;
  final FormFieldValidator<String>? validator;

  const FormInputField({
    super.key,
    required this.id,
    required this.label,
    this.hintText = "",
    this.keyboardType = TextInputType.text,
    this.obscureText = false,
    this.validator,
  });

  @override
  Widget build(BuildContext context) {
    final formProvider = FormProvider.of(context);
    if (formProvider == null) {
      return const SizedBox();
    }

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          label,
          style: const TextStyle(
            fontSize: 16,
            fontWeight: FontWeight.w500,
          ),
        ),
        const SizedBox(height: 8),
        TextFormField(
          initialValue: formProvider.getValue(id)?.toString() ?? "",
          keyboardType: keyboardType,
          obscureText: obscureText,
          decoration: InputDecoration(
            hintText: hintText,
            border: const OutlineInputBorder(),
            errorText: formProvider.isTouched(id) ? formProvider.getError(id) : null,
          ),
          onChanged: (value) {
            formProvider.setValue(id, value);
          },
        ),
        const SizedBox(height: 16),
      ],
    );
  }
}

// 5. 使用表單組件庫
class RegisterFormDemo extends StatelessWidget {
  const RegisterFormDemo({super.key});

  void _onValidationChanged(bool isValid) {
    print("表單驗證狀態(tài): ${isValid ? "有效" : "無效"}");
  }

  void _onSubmit(Map<String, dynamic> values) {
    print("表單提交數(shù)據(jù): $values");
    // 這里可以處理表單提交邏輯,如網(wǎng)絡(luò)請求等
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('注冊表單')),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: FormContainer(
          onValidationChanged: _onValidationChanged,
          onSubmit: _onSubmit,
          child: Column(
            children: [
              FormInputField(
                id: "username",
                label: "用戶名",
                hintText: "請輸入用戶名",
              ),
              FormInputField(
                id: "email",
                label: "郵箱",
                hintText: "請輸入郵箱",
                keyboardType: TextInputType.emailAddress,
              ),
              FormInputField(
                id: "password",
                label: "密碼",
                hintText: "請輸入密碼",
                obscureText: true,
              ),
            ],
          ),
        ),
      ),
    );
  }
}
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容