4、Flutter组件化封装与复用实战:自定义Button组件的封装。
做Flutter开发久了,你会发现一个现象:每个项目里都有几十个按钮。登录按钮、提交按钮、删除按钮、带图标的按钮、带loading的按钮……
我刚开始带团队的时候,就吃过这个亏。项目做到一半,产品经理说「所有主按钮的圆角从8改成12」。好家伙,我全局搜了50多处,一个一个改,改完还漏了两个。从那以后,我就下定决心:按钮必须封装。
今天我们就来手撸一个企业级的自定义Button组件。说白了,就是让你以后写按钮时,只用改一个地方,全项目都跟着变。
4.1 为什么要封装Button?
你可能会想:「Flutter自带的ElevatedButton、TextButton不是挺好用的吗?」
嗯,确实能用。但你在实际项目中会遇到几个痛点:
- 样式不统一:设计师给的圆角、阴影、颜色,每个页面都得重新写一遍
- 状态管理麻烦:loading状态、禁用状态、按下状态,每个按钮都要自己维护
- 扩展性差:想加个左图标、右图标、渐变背景?原生Button得写一堆嵌套
核心思想:封装不是「为了封装而封装」,而是为了一处修改,全局生效。
4.2 设计思路:从需求反推结构
我个人习惯先列需求,再写代码。一个通用的Button组件,至少需要支持:
- 多种类型:主按钮、次按钮、文字按钮、危险按钮
- 多种状态:normal、loading、disabled
- 灵活的内容:纯文字、文字+左图标、文字+右图标、纯图标
- 自定义样式:圆角、背景色、文字颜色、边框、阴影
- 点击回调:支持异步点击,自动处理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组件的封装,到时候会用到今天学的配置类模式。嗯,先消化一下今天的代码吧。