首页 关于 文章 作品集 工具 联系
返回文章列表

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 层级更清晰。