Flutter 自定义 Widget 实战:封装通用按钮 + 下拉刷新列表

导语

Flutter 组件化开发的核心是 "复用与统一"------ 重复编写按钮、列表等基础组件会导致代码冗余、样式不统一、维护成本高。本文聚焦两个高频通用组件(通用按钮CommonButton、下拉刷新列表RefreshList)的封装,从核心需求拆解、代码实现到实战应用,完整讲解 Widget 封装的核心技巧(参数设计、样式统一、状态隔离、回调规范),让你的组件开箱即用、灵活扩展!

一、封装通用按钮:CommonButton(一站式按钮解决方案)

核心需求拆解

一个高复用的按钮需覆盖业务中绝大多数场景:✅ 支持多类型(主要 / 次要 / 文本按钮)✅ 兼容加载中、禁用状态✅ 自定义尺寸、圆角、颜色✅ 统一点击回调逻辑✅ 样式自适应状态变化(禁用 / 加载)

1. 完整代码实现(带详细注释)

dart

复制代码
import 'package:flutter/material.dart';

/// 按钮类型枚举(语义化区分按钮样式)
enum ButtonType {
  primary, // 主要按钮(强调操作,如提交、确认)
  secondary, // 次要按钮(辅助操作,如取消、返回)
  text, // 文本按钮(轻量化操作,如查看更多)
}

/// 通用按钮组件(支持多类型、加载/禁用状态、自定义样式)
class CommonButton extends StatelessWidget {
  // 核心必选参数
  final String text; // 按钮文字
  final VoidCallback? onPressed; // 点击回调(null时自动禁用)

  // 可选参数(带默认值,降低使用成本)
  final ButtonType type; // 按钮类型
  final bool isLoading; // 是否加载中(加载时禁用点击)
  final bool isDisabled; // 是否禁用
  final double? width; // 宽度(默认撑满父容器)
  final double height; // 高度(默认48px,符合移动端交互规范)
  final double borderRadius; // 圆角(默认8px)
  final Color? backgroundColor; // 自定义背景色(覆盖默认类型色)
  final Color? textColor; // 自定义文字色(覆盖默认类型色)
  final double fontSize; // 字体大小(默认16px)

  const CommonButton({
    super.key,
    required this.text,
    this.onPressed,
    this.type = ButtonType.primary,
    this.isLoading = false,
    this.isDisabled = false,
    this.width,
    this.height = 48,
    this.borderRadius = 8,
    this.backgroundColor,
    this.textColor,
    this.fontSize = 16,
  });

  /// 封装按钮样式逻辑(隔离样式计算,便于维护)
  ButtonStyle _getButtonStyle() {
    // 基础颜色配置(按类型赋值)
    Color bgColor = Colors.transparent;
    Color txtColor = Colors.black87;

    switch (type) {
      case ButtonType.primary:
        bgColor = backgroundColor ?? Colors.blue;
        txtColor = textColor ?? Colors.white;
        break;
      case ButtonType.secondary:
        bgColor = backgroundColor ?? Colors.grey.shade200;
        txtColor = textColor ?? Colors.black87;
        break;
      case ButtonType.text:
        bgColor = Colors.transparent;
        txtColor = textColor ?? Colors.blue;
        break;
    }

    // 禁用/加载状态颜色适配(视觉弱化)
    if (isDisabled || isLoading) {
      bgColor = type == ButtonType.primary
          ? Colors.blue.shade300
          : (type == ButtonType.text ? Colors.transparent : Colors.grey.shade100);
      txtColor = type == ButtonType.text
          ? Colors.grey.shade400
          : Colors.grey.shade500;
    }

    // 统一返回按钮样式
    return ElevatedButton.styleFrom(
      backgroundColor: bgColor,
      foregroundColor: txtColor,
      minimumSize: Size(width ?? double.infinity, height), // 宽度默认撑满
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(borderRadius),
      ),
      disabledBackgroundColor: bgColor, // 禁用状态背景色(避免默认灰色)
      disabledForegroundColor: txtColor, // 禁用状态文字色
      elevation: type == ButtonType.text ? 0 : 2, // 文本按钮无阴影
    );
  }

  @override
  Widget build(BuildContext context) {
    // 加载中状态:显示加载动画 + 文字
    Widget buttonChild = isLoading
        ? Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const SizedBox(
                width: 20,
                height: 20,
                child: CircularProgressIndicator(
                  strokeWidth: 2,
                  color: Colors.white,
                ),
              ),
              const SizedBox(width: 10),
              Text(text, style: TextStyle(fontSize: fontSize)),
            ],
          )
        : Text(
            text,
            style: TextStyle(
              fontSize: fontSize,
              fontWeight: type == ButtonType.primary ? FontWeight.w500 : FontWeight.normal,
            ),
          );

    return ElevatedButton(
      onPressed: (isDisabled || isLoading) ? null : onPressed, // 禁用/加载时不可点击
      style: _getButtonStyle(),
      child: buttonChild,
    );
  }
}

2. 实战使用示例(覆盖全场景)

dart

复制代码
class CommonButtonDemo extends StatelessWidget {
  const CommonButtonDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('通用按钮示例')),
      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 15),
        child: Column(
          children: [
            // 1. 主要按钮(默认类型)
            CommonButton(
              text: '主要按钮',
              onPressed: () => debugPrint('主要按钮点击'),
            ),
            const SizedBox(height: 10),

            // 2. 次要按钮
            CommonButton(
              text: '次要按钮',
              onPressed: () => debugPrint('次要按钮点击'),
              type: ButtonType.secondary,
            ),
            const SizedBox(height: 10),

            // 3. 文本按钮
            CommonButton(
              text: '文本按钮',
              onPressed: () => debugPrint('文本按钮点击'),
              type: ButtonType.text,
            ),
            const SizedBox(height: 10),

            // 4. 加载中按钮
            CommonButton(
              text: '提交中...',
              isLoading: true,
              type: ButtonType.primary,
            ),
            const SizedBox(height: 10),

            // 5. 禁用按钮
            CommonButton(
              text: '禁用按钮',
              isDisabled: true,
              type: ButtonType.primary,
            ),
            const SizedBox(height: 10),

            // 6. 自定义样式按钮(覆盖默认颜色/尺寸)
            CommonButton(
              text: '自定义按钮',
              onPressed: () => debugPrint('自定义按钮点击'),
              backgroundColor: Colors.green,
              textColor: Colors.white,
              width: 200,
              height: 44,
              borderRadius: 22,
              fontSize: 18,
            ),
          ],
        ),
      ),
    );
  }
}

✅ 按钮封装优化亮点

  1. 语义化枚举 :通过ButtonType区分按钮类型,代码可读性更高,避免魔法值。
  2. 状态自适应:禁用 / 加载状态自动调整颜色,无需外部重复判断。
  3. 默认值合理:宽度默认撑满、高度 48px、圆角 8px,符合移动端设计规范。
  4. 扩展性强:预留自定义颜色 / 尺寸参数,可覆盖默认样式,适配不同业务场景。
  5. 样式隔离 :将样式计算抽离为_getButtonStyle方法,避免 build 方法冗余。

二、封装下拉刷新列表:RefreshList(全状态适配)

核心需求拆解

下拉刷新列表是移动端高频组件,需覆盖完整业务场景:✅ 下拉刷新 + 上拉加载更多✅ 加载中 / 空数据 / 加载失败状态✅ 自定义列表项、状态提示文本✅ 防重复加载(加载中不触发二次请求)✅ 滚动监听自动触发加载更多

1. 完整代码实现(带详细注释)

dart

复制代码
import 'package:flutter/material.dart';
import 'common_button.dart'; // 引入通用按钮

/// 回调函数类型定义(语义化,便于理解)
typedef OnRefresh = Future<void> Function(); // 下拉刷新回调
typedef OnLoadMore = Future<void> Function(); // 上拉加载更多回调
typedef ItemBuilder = Widget Function(dynamic item, int index); // 列表项构建器

/// 下拉刷新列表组件(支持空/加载/错误/加载更多状态)
class RefreshList extends StatefulWidget {
  // 必选参数
  final List data; // 列表数据源
  final ItemBuilder itemBuilder; // 列表项构建方法
  final OnRefresh onRefresh; // 下拉刷新回调

  // 可选参数(带默认值)
  final OnLoadMore? onLoadMore; // 上拉加载更多回调
  final bool hasMore; // 是否有更多数据
  final String? emptyText; // 空数据提示文本
  final String? errorText; // 加载失败提示文本
  final bool isLoading; // 整体加载中(首次加载/刷新)
  final bool isError; // 加载失败

  const RefreshList({
    super.key,
    required this.data,
    required this.itemBuilder,
    required this.onRefresh,
    this.onLoadMore,
    this.hasMore = false,
    this.emptyText = '暂无数据',
    this.errorText = '加载失败,请重试',
    this.isLoading = false,
    this.isError = false,
  });

  @override
  State<RefreshList> createState() => _RefreshListState();
}

class _RefreshListState extends State<RefreshList> {
  final ScrollController _scrollController = ScrollController(); // 滚动控制器

  @override
  void initState() {
    super.initState();
    // 监听滚动位置,触发加载更多
    _scrollController.addListener(_onScroll);
  }

  @override
  void dispose() {
    _scrollController.dispose(); // 释放控制器,避免内存泄漏
    super.dispose();
  }

  /// 滚动监听:到达底部触发加载更多
  void _onScroll() {
    // 距离底部100px时触发,避免滚动到底才加载
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent - 100) {
      // 防重复加载:有更多数据 + 非加载中 + 有加载更多回调
      if (widget.hasMore && !widget.isLoading && widget.onLoadMore != null) {
        widget.onLoadMore!();
      }
    }
  }

  /// 构建状态页面(空/加载中/加载失败)
  Widget _buildStatusWidget() {
    if (widget.isLoading) {
      // 加载中状态
      return const Center(
        child: CircularProgressIndicator(),
      );
    }
    if (widget.isError) {
      // 加载失败状态(带重试按钮)
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(widget.errorText!),
            const SizedBox(height: 20),
            CommonButton(
              text: '重试',
              type: ButtonType.secondary,
              width: 120,
              height: 40,
              onPressed: widget.onRefresh,
            ),
          ],
        ),
      );
    }
    if (widget.data.isEmpty) {
      // 空数据状态
      return Center(
        child: Text(widget.emptyText!),
      );
    }
    // 无状态:空组件
    return const SizedBox.shrink();
  }

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      color: Colors.blue, // 刷新指示器颜色
      onRefresh: widget.onRefresh,
      child: ListView.builder(
        controller: _scrollController,
        physics: const AlwaysScrollableScrollPhysics(), // 确保空数据时也能下拉刷新
        itemCount: widget.data.length + (widget.hasMore ? 1 : 0), // 加载更多占位项+1
        itemBuilder: (context, index) {
          // 1. 加载更多占位项
          if (index == widget.data.length) {
            return const Padding(
              padding: EdgeInsets.symmetric(vertical: 20),
              child: Center(
                child: CircularProgressIndicator(strokeWidth: 2),
              ),
            );
          }

          // 2. 空/加载中/加载失败状态(占满屏幕高度)
          if (widget.data.isEmpty || widget.isLoading || widget.isError) {
            return SizedBox(
              height: MediaQuery.of(context).size.height - 100, // 适配屏幕高度
              child: _buildStatusWidget(),
            );
          }

          // 3. 正常列表项
          return widget.itemBuilder(widget.data[index], index);
        },
      ),
    );
  }
}

2. 实战使用示例(模拟真实业务场景)

dart

复制代码
class RefreshListDemo extends StatefulWidget {
  const RefreshListDemo({super.key});

  @override
  State<RefreshListDemo> createState() => _RefreshListDemoState();
}

class _RefreshListDemoState extends State<RefreshListDemo> {
  List<String> _listData = []; // 列表数据
  bool _hasMore = true; // 是否有更多数据
  bool _isLoading = false; // 加载中
  bool _isError = false; // 加载失败

  @override
  void initState() {
    super.initState();
    _loadInitialData(); // 初始化加载数据
  }

  /// 初始化加载数据(模拟网络请求)
  Future<void> _loadInitialData() async {
    setState(() {
      _isLoading = true;
      _isError = false;
    });

    try {
      // 模拟1秒网络请求
      await Future.delayed(const Duration(seconds: 1));
      setState(() {
        _listData = List.generate(20, (index) => '列表项 ${index + 1}');
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _isLoading = false;
        _isError = true;
      });
    }
  }

  /// 下拉刷新(重新加载数据)
  Future<void> _onRefresh() async {
    await Future.delayed(const Duration(seconds: 1));
    setState(() {
      _listData = List.generate(20, (index) => '刷新后列表项 ${index + 1}');
      _hasMore = true; // 刷新后重置加载更多状态
    });
  }

  /// 上拉加载更多
  Future<void> _onLoadMore() async {
    if (_isLoading) return; // 防重复加载
    setState(() {
      _isLoading = true;
    });

    try {
      await Future.delayed(const Duration(seconds: 1));
      setState(() {
        // 追加10条数据
        _listData.addAll(List.generate(
          10,
          (index) => '加载更多项 ${_listData.length + index + 1}',
        ));
        _isLoading = false;
        _hasMore = _listData.length < 50; // 最多加载50条数据
      });
    } catch (e) {
      setState(() {
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('下拉刷新列表示例')),
      body: RefreshList(
        data: _listData,
        itemBuilder: (item, index) {
          // 自定义列表项
          return ListTile(
            leading: const Icon(Icons.list_alt, color: Colors.blue),
            title: Text(item),
            trailing: const Icon(Icons.arrow_forward_ios, size: 16),
            onTap: () => debugPrint('点击列表项 $index'),
          );
        },
        onRefresh: _onRefresh,
        onLoadMore: _onLoadMore,
        hasMore: _hasMore,
        isLoading: _isLoading,
        isError: _isError,
        emptyText: '暂无列表数据~',
        errorText: '数据加载失败,点击重试',
      ),
    );
  }
}

✅ 列表封装优化亮点

  1. 防重复加载:加载中不触发二次请求,避免接口重复调用。
  2. 状态全覆盖:支持加载中 / 空数据 / 加载失败 / 加载更多,适配全业务场景。
  3. 滚动优化:距离底部 100px 提前触发加载更多,提升用户体验。
  4. 内存安全 :及时释放ScrollController,避免内存泄漏。
  5. 交互友好:空数据时仍可下拉刷新,加载失败提供重试按钮。

三、Widget 封装核心技巧(通用方法论)

技巧分类 具体实现 优势
参数设计 区分必选(required)/ 可选参数,提供合理默认值 降低使用成本,避免空指针,适配多数场景
样式统一 抽离样式计算逻辑(如_getButtonStyle),枚举管理类型 避免重复代码,样式统一可维护
状态隔离 无状态组件(CommonButton)+ 有状态组件(RefreshList)分离 状态逻辑内聚,UI 与状态解耦
回调设计 自定义typedef语义化回调类型,统一回调格式 代码可读性高,回调逻辑清晰
扩展性 预留自定义属性(如颜色、尺寸、提示文本) 适配不同业务场景,无需重复封装
性能优化 及时释放控制器、防重复加载、局部刷新 避免内存泄漏,提升运行效率
语义化命名 枚举(ButtonType)、回调类型(OnRefresh)、方法名(_onScroll 降低协作成本,便于后期维护

四、进阶优化建议

  1. 通用按钮扩展:支持图标按钮、渐变背景、自定义加载动画,适配更多设计风格。
  2. 刷新列表扩展:添加列表项点击防抖、预加载(提前加载下一页)、滑动删除功能。
  3. 主题适配 :结合Theme.of(context)统一按钮 / 列表样式,支持暗黑模式。
  4. 封装组件库:将通用组件抽离为单独 package,便于多项目复用。
  5. 单元测试:为通用组件编写测试用例,覆盖不同状态和参数,保证稳定性。

掌握以上封装技巧,可快速打造高复用、易维护的通用组件库,大幅提升 Flutter 开发效率。

相关推荐
Li_na_na011 小时前
React+dhtmlx实现甘特图
前端·react.js·甘特图
艾体宝IT1 小时前
艾体宝干货 | Redis Java 开发系列#2 数据结构
javascript
用户2965412759171 小时前
JSAPIThree 加载 Cesium 数据学习笔记:使用 Cesium 地形和影像服务
前端
csdn小瓯1 小时前
一个现代化的博客应用【react+ts】
前端·react.js·前端框架
一颗不甘坠落的流星1 小时前
【@ebay/nice-modal-react】管理React弹窗(Modal)状态
前端·javascript·react.js
黛色正浓1 小时前
【React】极客园案例实践-Layout模块
前端·react.js·前端框架
辛-夷1 小时前
vue高频面试题
前端·vue.js
IT小哥哥呀1 小时前
《纯前端实现 Excel 导入导出:基于 SheetJS 的完整实战》
前端·excel
郑州光合科技余经理1 小时前
技术架构:跑腿配送系统海外版源码全解析
java·开发语言·前端·数据库·架构·uni-app·php