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 开发效率。

相关推荐
崔庆才丨静觅5 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了6 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅6 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅6 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
renke33646 小时前
Flutter for OpenHarmony:色彩捕手——基于HSL色轮与感知色差的交互式色觉训练系统
flutter
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅7 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊7 小时前
jwt介绍
前端