4、Flutter组件化封装与复用实战:自定义Button组件的封装。

做Flutter开发久了,你会发现一个现象:每个项目里都有几十个按钮。登录按钮、提交按钮、删除按钮、带图标的按钮、带loading的按钮……

我刚开始带团队的时候,就吃过这个亏。项目做到一半,产品经理说「所有主按钮的圆角从8改成12」。好家伙,我全局搜了50多处,一个一个改,改完还漏了两个。从那以后,我就下定决心:按钮必须封装

今天我们就来手撸一个企业级的自定义Button组件。说白了,就是让你以后写按钮时,只用改一个地方,全项目都跟着变。

4.1 为什么要封装Button?

你可能会想:「Flutter自带的ElevatedButton、TextButton不是挺好用的吗?」

嗯,确实能用。但你在实际项目中会遇到几个痛点:

  • 样式不统一:设计师给的圆角、阴影、颜色,每个页面都得重新写一遍
  • 状态管理麻烦:loading状态、禁用状态、按下状态,每个按钮都要自己维护
  • 扩展性差:想加个左图标、右图标、渐变背景?原生Button得写一堆嵌套

核心思想:封装不是「为了封装而封装」,而是为了一处修改,全局生效

4.2 设计思路:从需求反推结构

我个人习惯先列需求,再写代码。一个通用的Button组件,至少需要支持:

  1. 多种类型:主按钮、次按钮、文字按钮、危险按钮
  2. 多种状态:normal、loading、disabled
  3. 灵活的内容:纯文字、文字+左图标、文字+右图标、纯图标
  4. 自定义样式:圆角、背景色、文字颜色、边框、阴影
  5. 点击回调:支持异步点击,自动处理loading状态

你想想看,如果把这些都揉进一个构造函数里,那参数列表得有多长?所以我们需要用Builder模式或者配置类来解耦。

4.3 代码实现:一步一步来

4.3.1 定义按钮类型枚举

先定个调子,按钮有哪些「长相」:

enum AppButtonType {
  primary,   // 主按钮,蓝色背景白色文字
  secondary, // 次按钮,灰色边框灰色文字
  text,      // 文字按钮,无背景
  danger,    // 危险按钮,红色背景
}

4.3.2 定义配置类

这里我用了一个小技巧:把样式配置抽成单独类。这样既方便扩展,又不会污染Widget的构造函数。

class AppButtonConfig {
  final double height;
  final double borderRadius;
  final double fontSize;
  final Color? backgroundColor;
  final Color? textColor;
  final Color? borderColor;
  final double borderWidth;
  final List<Color>? gradientColors; // 渐变支持
  final EdgeInsets padding;
  final double? elevation;

  const AppButtonConfig({
    this.height = 48.0,
    this.borderRadius = 8.0,
    this.fontSize = 16.0,
    this.backgroundColor,
    this.textColor,
    this.borderColor,
    this.borderWidth = 0.0,
    this.gradientColors,
    this.padding = const EdgeInsets.symmetric(horizontal: 24.0),
    this.elevation,
  });

  // 根据类型返回默认配置
  factory AppButtonConfig.fromType(AppButtonType type) {
    switch (type) {
      case AppButtonType.primary:
        return const AppButtonConfig(
          backgroundColor: Color(0xFF2196F3),
          textColor: Colors.white,
          elevation: 2.0,
        );
      case AppButtonType.secondary:
        return const AppButtonConfig(
          backgroundColor: Colors.white,
          textColor: Color(0xFF2196F3),
          borderColor: Color(0xFF2196F3),
          borderWidth: 1.0,
        );
      case AppButtonType.text:
        return const AppButtonConfig(
          backgroundColor: Colors.transparent,
          textColor: Color(0xFF2196F3),
          elevation: 0.0,
        );
      case AppButtonType.danger:
        return const AppButtonConfig(
          backgroundColor: Color(0xFFE53935),
          textColor: Colors.white,
          elevation: 2.0,
        );
    }
  }
}

我的经验:配置类里用factory构造函数来提供「默认皮肤」,调用方可以覆盖任意属性。这样既保留了默认值,又给了灵活性。

4.3.3 核心组件实现

接下来是重头戏——AppButton组件本身。我把它设计成一个有状态组件,因为要管理内部的loading状态。

class AppButton extends StatefulWidget {
  final String? text;
  final Widget? icon;
  final IconPosition iconPosition; // left 或 right
  final AppButtonType type;
  final AppButtonConfig? config;
  final VoidCallback? onPressed;
  final Future<void> Function()? onAsyncPressed; // 异步点击
  final bool disabled;

  const AppButton({
    Key? key,
    this.text,
    this.icon,
    this.iconPosition = IconPosition.left,
    this.type = AppButtonType.primary,
    this.config,
    this.onPressed,
    this.onAsyncPressed,
    this.disabled = false,
  }) : super(key: key);

  @override
  State<AppButton> createState() => _AppButtonState();
}

class _AppButtonState extends State<AppButton> {
  bool _isLoading = false;

  Future<void> _handleTap() async {
    if (_isLoading || widget.disabled) return;

    if (widget.onAsyncPressed != null) {
      setState(() => _isLoading = true);
      try {
        await widget.onAsyncPressed!();
      } finally {
        if (mounted) {
          setState(() => _isLoading = false);
        }
      }
    } else {
      widget.onPressed?.call();
    }
  }

  @override
  Widget build(BuildContext context) {
    final config = widget.config ?? AppButtonConfig.fromType(widget.type);

    // 构建按钮内容
    List<Widget> children = [];
    if (_isLoading) {
      children.add(
        SizedBox(
          width: 20,
          height: 20,
          child: CircularProgressIndicator(
            strokeWidth: 2,
            valueColor: AlwaysStoppedAnimation<Color>(config.textColor!),
          ),
        ),
      );
    } else {
      if (widget.icon != null && widget.iconPosition == IconPosition.left) {
        children.add(widget.icon!);
        children.add(const SizedBox(width: 8));
      }
      if (widget.text != null) {
        children.add(
          Text(
            widget.text!,
            style: TextStyle(
              fontSize: config.fontSize,
              color: config.textColor,
              fontWeight: FontWeight.w500,
            ),
          ),
        );
      }
      if (widget.icon != null && widget.iconPosition == IconPosition.right) {
        children.add(const SizedBox(width: 8));
        children.add(widget.icon!);
      }
    }

    // 构建按钮样式
    final buttonStyle = ButtonStyle(
      padding: MaterialStateProperty.all(config.padding),
      minimumSize: MaterialStateProperty.all(Size(0, config.height)),
      shape: MaterialStateProperty.all(
        RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(config.borderRadius),
          side: BorderSide(
            color: widget.disabled
                ? Colors.grey.shade300
                : (config.borderColor ?? Colors.transparent),
            width: config.borderWidth,
          ),
        ),
      ),
      elevation: MaterialStateProperty.all(
        widget.disabled ? 0 : (config.elevation ?? 0),
      ),
      backgroundColor: MaterialStateProperty.all(
        widget.disabled ? Colors.grey.shade200 : config.backgroundColor,
      ),
    );

    return SizedBox(
      height: config.height,
      child: ElevatedButton(
        onPressed: (widget.disabled || _isLoading) ? null : _handleTap,
        style: buttonStyle,
        child: Row(
          mainAxisSize: MainAxisSize.min,
          mainAxisAlignment: MainAxisAlignment.center,
          children: children,
        ),
      ),
    );
  }
}

注意:异步点击时,一定要用mounted检查组件是否还在树中。我曾经在页面销毁后还调用了setState,直接导致内存泄漏。嗯,血的教训。

4.4 使用示例

封装好了,用起来就爽了:

// 最简单的用法
AppButton(
  text: '登录',
  onPressed: () => print('登录'),
)

// 带loading的异步按钮
AppButton(
  text: '提交订单',
  type: AppButtonType.danger,
  onAsyncPressed: () async {
    await Future.delayed(Duration(seconds: 2));
    print('提交成功');
  },
)

// 带图标的按钮
AppButton(
  text: '下载文件',
  icon: Icon(Icons.download, color: Colors.white, size: 18),
  iconPosition: IconPosition.left,
  onPressed: () => print('下载'),
)

// 自定义样式
AppButton(
  text: '自定义按钮',
  config: AppButtonConfig(
    borderRadius: 20,
    gradientColors: [Colors.orange, Colors.red],
    fontSize: 18,
  ),
  onPressed: () => print('自定义'),
)

4.5 避坑指南

最后分享几个我踩过的坑:

  • 不要直接修改config的默认值:如果你在代码里改了AppButtonConfig.fromType的返回值,会影响所有使用默认配置的按钮。正确的做法是复制一份再改
  • loading状态要防重复点击:用户手快,一秒点十下。如果不用_isLoading拦截,你的接口会被调用十次。
  • 按钮高度要固定:不同页面按钮高度不一致,设计稿会很难看。我一般统一用48px,小按钮用36px。

总结一下:自定义Button组件的核心就三点——配置可扩展、状态可管理、样式可覆盖。做到这三点,你的按钮组件就能在项目中「一招鲜,吃遍天」。

下一章我们会讲自定义TextField组件的封装,到时候会用到今天学的配置类模式。嗯,先消化一下今天的代码吧。