返回文章列表
Flutter 自定义 Widget 与复杂 UI 构建实战
Flutter 的核心理念是"一切皆 Widget"。当你发现系统组件无法满足需求时,自定义 Widget 就派上用场了。这篇文章从实际项目出发,讲解如何构建高质量的自定义组件——包括组合型 Widget、自定义绘制(CustomPaint)、带动画的交互组件,以及复杂布局的最佳实践。
一、Widget 设计原则
在动手之前,先明确几个设计原则:
- 单一职责:一个 Widget 只做一件事。如果它做了太多事,就拆分成更小的 Widget。
- 组合优于继承:Flutter 推崇通过组合小 Widget 来构建复杂 UI,而不是通过继承来扩展。
- 参数驱动:通过构造函数参数来控制 Widget 的外观和行为,而不是硬编码。
- const 优先:尽可能使用 const 构造函数,减少不必要的重建。
二、组合型自定义组件
最常见也最推荐的自定义方式,通过组合现有 Widget 来创建新组件。
2.1 带动画的评分星星组件
import 'package:flutter/material.dart';
class StarRating extends StatelessWidget {
final int rating;
final int maxRating;
final double size;
final Color activeColor;
final Color inactiveColor;
final ValueChanged<int>? onChanged;
const StarRating({
Key? key,
required this.rating,
this.maxRating = 5,
this.size = 24,
this.activeColor = Colors.amber,
this.inactiveColor = Colors.grey,
this.onChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(maxRating, (index) {
final isActive = index < rating;
return GestureDetector(
onTap: onChanged != null ? () => onChanged!(index + 1) : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
transform: isActive
? (Matrix4.identity()..scale(1.1))
: Matrix4.identity(),
child: Icon(
isActive ? Icons.star_rounded : Icons.star_outline_rounded,
size: size,
color: isActive ? activeColor : inactiveColor,
),
),
);
}),
);
}
}
// 使用
StarRating(
rating: 4,
size: 32,
onChanged: (newRating) {
print('新评分: $newRating');
},
)
2.2 可复用的加载按钮
class LoadingButton extends StatefulWidget {
final String text;
final bool isLoading;
final VoidCallback? onPressed;
final Color? color;
final double? width;
final double height;
const LoadingButton({
Key? key,
required this.text,
this.isLoading = false,
this.onPressed,
this.color,
this.width,
this.height = 48,
}) : super(key: key);
@override
State<LoadingButton> createState() => _LoadingButtonState();
}
class _LoadingButtonState extends State<LoadingButton> {
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.width,
height: widget.height,
child: ElevatedButton(
onPressed: widget.isLoading ? null : widget.onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: widget.color ?? Theme.of(context).primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: widget.isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white.withOpacity(0.8),
),
)
: Text(widget.text, style: TextStyle(fontSize: 16)),
),
);
}
}
三、CustomPaint 自定义绘制
当系统 Widget 无法满足时,CustomPaint 让你可以直接用 Canvas API 绘制任何图形。
3.1 圆形进度条
class CircularProgressPainter extends CustomPainter {
final double progress;
final Color color;
final double strokeWidth;
final Color backgroundColor;
CircularProgressPainter({
required this.progress,
this.color = Colors.blue,
this.strokeWidth = 8,
this.backgroundColor = Colors.grey,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
// 背景圆环
final bgPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, bgPaint);
// 进度圆环
final progressPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
final sweepAngle = 2 * 3.1415926 * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-3.1415926 / 2, // 从顶部开始
sweepAngle,
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant CircularProgressPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.color != color;
}
}
// 使用
CustomPaint(
size: Size(120, 120),
painter: CircularProgressPainter(
progress: 0.75,
color: Colors.green,
),
)
3.2 带动画的波浪进度
class WaveProgress extends StatefulWidget {
final double progress;
final Color waveColor;
final double size;
const WaveProgress({
Key? key,
this.progress = 0.5,
this.waveColor = Colors.blue,
this.size = 150,
}) : super(key: key);
@override
State<WaveProgress> createState() => _WaveProgressState();
}
class _WaveProgressState extends State<WaveProgress>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
size: Size(widget.size, widget.size),
painter: WavePainter(
progress: widget.progress,
animationValue: _controller.value,
color: widget.waveColor,
),
);
},
);
}
}
class WavePainter extends CustomPainter {
final double progress;
final double animationValue;
final Color color;
WavePainter({
required this.progress,
required this.animationValue,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
// 裁剪圆形区域
canvas.clipPath(Path()..addOval(Rect.fromCircle(center: center, radius: radius)));
// 绘制波浪
final waveHeight = size.height * (1 - progress);
final paint = Paint()..color = color.withOpacity(0.6);
final path = Path();
path.moveTo(0, waveHeight);
for (double x = 0; x <= size.width; x++) {
final y = waveHeight +
math.sin((x / size.width * 2 * math.pi) + animationValue * 2 * math.pi) * 8;
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.lineTo(0, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant WavePainter oldDelegate) => true;
}
四、Sliver 系列构建复杂滚动列表
Sliver 系列 Widget 可以创建复杂的滚动效果,如折叠头部、吸顶标题等:
class ComplexListPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
// 可折叠的 AppBar
SliverAppBar(
expandedHeight: 200,
pinned: true, // 滚动后固定在顶部
flexibleSpace: FlexibleSpaceBar(
title: Text('复杂列表'),
background: Image.network(
'https://picsum.photos/800/400',
fit: BoxFit.cover,
),
),
),
// 吸顶标题
SliverPersistentHeader(
pinned: true,
delegate: _StickyHeaderDelegate(
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('推荐内容', style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
)),
),
),
),
// 网格列表
SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.8,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
return Card(
clipBehavior: Clip.antiAlias,
child: Image.network(
'https://picsum.photos/400/${300 + index}',
fit: BoxFit.cover,
),
);
},
childCount: 6,
),
),
// 普通列表
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return ListTile(
leading: CircleAvatar(
child: Text('${index + 1}'),
),
title: Text('列表项 ${index + 1}'),
subtitle: Text('这是第 ${index + 1} 个列表项的描述'),
);
},
childCount: 20,
),
),
],
),
);
}
}
五、性能优化技巧
- const 构造函数:能加 const 就加,Widget 重建时可以跳过
- RepaintBoundary:隔离重绘区域,减少不必要的绘制
- ListView.builder:长列表必须用 builder,不要直接用 children
- AutomaticKeepAliveClientMixin:TabView 中保持页面状态
- 避免在 build 中创建对象:TextEditingController 等应在 initState 中创建
总结
Flutter 的自定义 Widget 体系非常强大,从简单的组合到 Canvas 自定义绘制,覆盖了几乎所有的 UI 需求。记住核心原则:组合优于继承、单一职责、const 优先。好的自定义组件不仅能提高代码复用性,还能让整个项目的 UI 层级更清晰。