Flutter进阶:实现比赛对阵图/淘汰赛

一、需求来源

flutter 最近面试遇到了一个比赛对阵图的问题,网上也没发现开源的组件,今天随手实现一个简单版,分享给大家。大家稍微调整就可以使用在项目中。

二、使用示例

dart 复制代码
Container(
  height: 500,
  decoration: BoxDecoration(
    color: Colors.transparent,
    // border: Border.all(color: Colors.blue),
  ),
  child: GameMatchItem(
    imageUrl: 'https://flagcdn.com/w40/kr.png',
    text: '韩国男篮',
    imageUrlRight: 'https://flagcdn.com/w40/gum.png',
    textRight: '关岛男篮',
  ),
),

三、源码

dart 复制代码
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/util/R.dart';
import 'package:get/get.dart';

class GameMathPage extends StatefulWidget {
  const GameMathPage({
    super.key,
    this.arguments,
  });

  final Map<String, dynamic>? arguments;

  @override
  State<GameMathPage> createState() => _GameMathPageState();
}

class _GameMathPageState extends State<GameMathPage> {
  bool get hideApp => "$widget".toLowerCase().endsWith(Get.currentRoute.toLowerCase());

  final scrollController = ScrollController();

  Map<String, dynamic> arguments = Get.arguments ?? <String, dynamic>{};

  /// id
  late final id = arguments["id"];

  @override
  void didUpdateWidget(covariant GameMathPage oldWidget) {
    super.didUpdateWidget(oldWidget);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF1B1B1B),
      appBar: AppBar(
        title: Text('淘汰赛对阵图'),
      ),
      body: SafeArea(
        child: SingleChildScrollView(
          // scrollDirection: Axis.horizontal,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Container(
                height: 500,
                decoration: BoxDecoration(
                  color: Colors.transparent,
                  // border: Border.all(color: Colors.blue),
                ),
                child: GameMatchItem(
                  imageUrl: 'https://flagcdn.com/w40/kr.png',
                  text: '韩国男篮',
                  imageUrlRight: 'https://flagcdn.com/w40/gum.png',
                  textRight: '关岛男篮',
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

/// 比赛数据
final tournamentData = [
  {
    "team1": {"name": "韩国男篮", "flag": "https://flagcdn.com/w40/kr.png", "score": 99},
    "team2": {"name": "关岛男篮", "flag": "https://flagcdn.com/w40/gum.png", "score": 66}
  },
  {
    "team1": {"name": "日本男篮", "flag": "https://flagcdn.com/w40/jp.png", "score": 73},
    "team2": {"name": "黎巴嫩男篮", "flag": "https://flagcdn.com/w40/lb.png", "score": 97}
  }
];

/// 整个淘汰赛视图
class TournamentView extends StatelessWidget {
  const TournamentView({super.key});

  @override
  Widget build(BuildContext context) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        CustomPaint(
          size: const Size(50, 200),
          painter: BracketLinePainter(),
        ),
        Container(
          decoration: BoxDecoration(
            color: Colors.transparent,
            border: Border.all(color: Colors.blue),
          ),
          child: CustomPaint(
            size: const Size(200, 80),
            painter: BracketHorLinePainter(),
          ),
        ),
        Container(
          decoration: BoxDecoration(
            color: Colors.transparent,
            border: Border.all(color: Colors.blue),
          ),
          child: NetworkImageWithText(
            imageUrl: R.image.urls.first,
            text: "韩国男篮",
          ),
        ),
        // Flexible(
        //   child: const MatchCard(
        //     team1: {"name": "韩国男篮", "flag": "https://flagcdn.com/w40/kr.png", "score": null},
        //     team2: {"name": "黎巴嫩男篮", "flag": "https://flagcdn.com/w40/lb.png", "score": null},
        //   ),
        // ),
      ],
    );
  }
}

/// 单场比赛卡片
class MatchCard extends StatelessWidget {
  final Map<String, dynamic> team1;
  final Map<String, dynamic> team2;

  const MatchCard({
    super.key,
    required this.team1,
    required this.team2,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(6),
      decoration: BoxDecoration(
        color: const Color(0xFF2C2C2C),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Column(
        children: [
          Flexible(child: _buildTeamRow(team1)),
          const SizedBox(height: 4),
          Flexible(child: _buildTeamRow(team2)),
        ],
      ),
    );
  }

  Widget _buildTeamRow(Map<String, dynamic> team) {
    return Row(
      children: [
        Image.network(team["flag"], width: 24, height: 16, fit: BoxFit.cover),
        const SizedBox(width: 6),
        Flexible(
          child: Text(
            team["name"],
            style: const TextStyle(color: Colors.white, fontSize: 14),
            overflow: TextOverflow.ellipsis,
          ),
        ),
        if (team["score"] != null)
          Text(
            team["score"].toString(),
            style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
          ),
      ],
    );
  }
}

/// 连线绘制
class BracketLinePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.white38
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;

    // 上半场到中间
    canvas.drawLine(Offset(0, 25), Offset(size.width / 2, 25), paint);
    canvas.drawLine(Offset(size.width / 2, 25), Offset(size.width / 2, size.height / 2), paint);

    // 下半场到中间
    canvas.drawLine(Offset(0, size.height - 25), Offset(size.width / 2, size.height - 25), paint);
    canvas.drawLine(Offset(size.width / 2, size.height / 2), Offset(size.width / 2, size.height - 25), paint);

    // 中间到决赛
    canvas.drawLine(Offset(size.width / 2, size.height / 2), Offset(size.width, size.height / 2), paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

class BracketHorLinePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.white38
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;

    final paint2 = Paint()
      ..color = Colors.red
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;

    final paint3 = Paint()
      ..color = Colors.blue
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;

    double paddingX = 25;
    double paddingY = 10;

    double lineHori = (size.width - paddingX * 2) / 2;
    double lineVert = (size.height - paddingY * 2) / 2;

    // 上半场到中间
    canvas.drawLine(Offset(paddingX, paddingY), Offset(paddingX, size.height / 2), paint);
    canvas.drawLine(Offset(paddingX, size.height / 2), Offset(size.width / 2, size.height / 2), paint);

    // 下半场到中间
    canvas.drawLine(Offset(size.width / 2, size.height / 2), Offset(size.width - paddingX, size.height / 2), paint2);
    canvas.drawLine(Offset(size.width - paddingX, size.height / 2), Offset(size.width - paddingX, paddingY), paint2);

    // 中间到决赛
    canvas.drawLine(Offset(size.width / 2, size.height / 2), Offset(size.width / 2, size.height - paddingY), paint3);

    // 1. 定义文字内容与样式
    final textSpan = TextSpan(
      text: "99 - 66",
      style: const TextStyle(
        color: Colors.white,
        fontSize: 16,
        fontWeight: FontWeight.bold,
        backgroundColor: Colors.amberAccent,
      ),
    );

    // 2. 创建 TextPainter
    final textPainter = TextPainter(
      text: textSpan,
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr,
    );

    // 3. 排版
    textPainter.layout();

    // 4. 计算绘制位置(居中)
    // final offset = Offset(
    //   (size.width - textPainter.width) / 2,
    //   (size.height - textPainter.height) / 2,
    // );

    final offset = Offset(
      (size.width - textPainter.width) / 2,
      paddingY,
    );

    // 5. 绘制到 Canvas
    textPainter.paint(canvas, offset);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

class NetworkImageWithText extends StatefulWidget {
  final String imageUrl;
  final String text;

  const NetworkImageWithText({
    super.key,
    required this.imageUrl,
    required this.text,
  });

  @override
  State<NetworkImageWithText> createState() => _NetworkImageWithTextState();
}

class _NetworkImageWithTextState extends State<NetworkImageWithText> {
  ui.Image? _image;

  @override
  void initState() {
    super.initState();
    _loadImage(widget.imageUrl);
  }

  @override
  void didUpdateWidget(covariant NetworkImageWithText oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (_image == null) {
      _loadImage(widget.imageUrl);
    }
  }

  Future<void> _loadImage(String url) async {
    try {
      final response = await Dio().get<List<int>>(
        url,
        options: Options(responseType: ResponseType.bytes),
      );
      // 转成 Uint8List
      final Uint8List data = Uint8List.fromList(response.data!);
      final codec = await ui.instantiateImageCodec(data);
      final frame = await codec.getNextFrame();
      _image = frame.image;
      setState(() {});
    } catch (e) {
      debugPrint("图片加载失败: $e");
    }
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      size: const Size(80, 100),
      painter: _ImageTextPainter(_image, widget.text),
    );
  }
}

class _ImageTextPainter extends CustomPainter {
  final ui.Image? image;
  final String text;

  _ImageTextPainter(this.image, this.text);

  @override
  void paint(Canvas canvas, Size size) {
    if (image == null) {
      return;
    }

    // 文字绘制
    final textSpan = TextSpan(
      text: text,
      style: const TextStyle(color: Colors.white, fontSize: 14),
    );
    final textPainter = TextPainter(
      text: textSpan,
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr,
    );
    textPainter.layout();

    // 图片高度(限制最大高度)
    final imgHeight = size.height - textPainter.height - 8;
    final imgWidth = (imgHeight / image!.height) * image!.width;

    // 计算整体垂直居中
    final totalHeight = imgHeight + 8 + textPainter.height;
    final startY = (size.height - totalHeight) / 2;

    // 绘制图片(居中)
    final imgRect = Rect.fromLTWH(
      (size.width - imgWidth) / 2,
      startY,
      imgWidth,
      imgHeight,
    );
    paintImage(
      canvas: canvas,
      rect: imgRect,
      image: image!,
      fit: BoxFit.contain,
    );

    // 绘制文字(在图片下方居中)
    final textOffset = Offset(
      (size.width - textPainter.width) / 2,
      startY + imgHeight + 4,
    );
    textPainter.paint(canvas, textOffset);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

class GameMatchItem extends StatefulWidget {
  const GameMatchItem({
    super.key,
    required this.imageUrl,
    required this.text,
    required this.imageUrlRight,
    required this.textRight,
  });

  final String imageUrl;
  final String text;

  final String imageUrlRight;
  final String textRight;

  @override
  State<GameMatchItem> createState() => _GameMatchItemState();
}

class _GameMatchItemState extends State<GameMatchItem> {
  ui.Image? _image;
  ui.Image? _imageRight;

  @override
  void initState() {
    super.initState();

    initData();
  }

  initData() async {
    _image = await _loadImage(widget.imageUrl);
    // _imageRight = await _loadImage(widget.imageUrlRight);
  }

  @override
  void didUpdateWidget(covariant GameMatchItem oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (_image == null || _imageRight == null) {
      initData();
    }
  }

  Future<ui.Image?> _loadImage(String url) async {
    ui.Image? _image;
    try {
      final response = await Dio().get<List<int>>(
        url,
        options: Options(responseType: ResponseType.bytes),
      );
      // 转成 Uint8List
      final Uint8List data = Uint8List.fromList(response.data!);
      final codec = await ui.instantiateImageCodec(data);
      final frame = await codec.getNextFrame();
      _image = frame.image;
    } catch (e) {
      debugPrint("图片加载失败: $e");
    }
    return _image;
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      size: const Size(80, 100),
      painter: GameMatchItemPainter(
        image: _image,
        text: widget.text,
        imageRight: _imageRight,
        textRight: widget.textRight,
      ),
    );
  }
}

class GameMatchItemPainter extends CustomPainter {
  GameMatchItemPainter({
    required this.image,
    required this.text,
    required this.imageRight,
    required this.textRight,
  });

  final ui.Image? image;
  final String text;

  final ui.Image? imageRight;
  final String textRight;

  @override
  void paint(Canvas canvas, Size size) {
    double lineHori = 50;
    double lineVert = 20;

    double leve1Hori = lineHori * 2.4;
    double leve2Hori = lineHori * 1.2;
    double leve3Hori = lineHori * 0.6;

    leve1Hori = size.width / 4;
    leve2Hori = size.width / 4 - 3 * 10 - 20;
    leve3Hori = size.width / 8 - 7 * 4 + 4;

    final level0 = paintGameItem(canvas, size,
        startPoint: size.center(ui.Offset(0, 100)), lineVert: lineVert, lineHori: leve1Hori);
    final level10 =
        paintGameItem(canvas, size, startPoint: level0.leftEndPoint, lineVert: lineVert, lineHori: leve2Hori);
    final level11 =
        paintGameItem(canvas, size, startPoint: level0.rightEndPoint, lineVert: lineVert, lineHori: leve2Hori);

    final level20 =
        paintGameItem(canvas, size, startPoint: level10.leftEndPoint, lineVert: lineVert, lineHori: leve3Hori);
    final level21 =
        paintGameItem(canvas, size, startPoint: level10.rightEndPoint, lineVert: lineVert, lineHori: leve3Hori);

    final level22 =
        paintGameItem(canvas, size, startPoint: level11.leftEndPoint, lineVert: lineVert, lineHori: leve3Hori);
    final level23 =
        paintGameItem(canvas, size, startPoint: level11.rightEndPoint, lineVert: lineVert, lineHori: leve3Hori);

    final level0Bom = paintGameItem(canvas, size,
        isReverse: false, startPoint: size.center(ui.Offset(0, 100 + 60)), lineVert: lineVert, lineHori: leve1Hori);
    final level10Bom = paintGameItem(canvas, size,
        isReverse: false, startPoint: level0Bom.leftEndPoint, lineVert: lineVert, lineHori: leve2Hori);
    final level11Bom = paintGameItem(canvas, size,
        isReverse: false, startPoint: level0Bom.rightEndPoint, lineVert: lineVert, lineHori: leve2Hori);

    final level20Bom = paintGameItem(canvas, size,
        isReverse: false, startPoint: level10Bom.leftEndPoint, lineVert: lineVert, lineHori: leve3Hori);
    final level21Bom = paintGameItem(canvas, size,
        isReverse: false, startPoint: level10Bom.rightEndPoint, lineVert: lineVert, lineHori: leve3Hori);

    final level22Bom = paintGameItem(canvas, size,
        isReverse: false, startPoint: level11Bom.leftEndPoint, lineVert: lineVert, lineHori: leve3Hori);
    final level23Bom = paintGameItem(canvas, size,
        isReverse: false, startPoint: level11Bom.rightEndPoint, lineVert: lineVert, lineHori: leve3Hori);

    /// 决赛
    final left = size.center(ui.Offset(0 - 50, 165));
    final right = size.center(ui.Offset(0 + 50, 165));

    paintGameImageAndText(canvas, size, isReverse: true, startPoint: left, text: text, image: image);
    paintGameImageAndText(canvas, size, isReverse: true, startPoint: right, text: text, image: image);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;

  ({ui.Offset startPoint, ui.Offset leftEndPoint, ui.Offset rightEndPoint}) paintGameItem(
    Canvas canvas,
    Size size, {
    bool isReverse = true,
    required Offset startPoint,
    double lineHori = 60,
    double lineVert = 30,
  }) {
    double factor = isReverse == true ? 1.0 : -1.0;

    // 绘制曲线
    var line = paintGameLine(
      canvas,
      size,
      isReverse: isReverse,
      startPoint: startPoint,
      lineHori: lineHori,
      lineVert: lineVert,
    );

    // 比分
    customDrawTextCenter(
      canvas,
      point: Offset(
        line.startPoint.dx,
        line.startPoint.dy - (lineVert + lineVert / 2) * factor,
      ),
      text: "99 - 66",
      style: const TextStyle(
        color: Colors.white,
        fontSize: 10,
        fontWeight: FontWeight.bold,
        backgroundColor: Colors.green,
      ),
    );

    /// 绘制左边图文
    var left = paintGameImageAndText(
      canvas,
      size,
      isReverse: isReverse,
      startPoint: line.leftEndPoint,
      text: text,
      image: image,
    );

    /// 绘制右边图文
    var right = paintGameImageAndText(
      canvas,
      size,
      isReverse: isReverse,
      startPoint: line.rightEndPoint,
      text: textRight,
      image: image,
    );
    return (startPoint: startPoint, leftEndPoint: left.endPoint, rightEndPoint: right.endPoint);
  }

  /// 绘制直线
  ({ui.Offset startPoint, ui.Offset leftEndPoint, ui.Offset rightEndPoint}) paintGameLine(
    Canvas canvas,
    Size size, {
    required bool isReverse,
    required ui.Offset startPoint,
    double lineHori = 120,
    double lineVert = 60,
  }) {
    final paintCenter = Paint()
      ..color = Colors.white38
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;

    final paintLeft = Paint()
      ..color = Colors.red
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;

    final paintRight = Paint()
      ..color = Colors.blue
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke;

    var centerPoint = Offset(startPoint.dx, startPoint.dy - lineVert);
    var pointLeft1 = Offset(centerPoint.dx - lineHori, centerPoint.dy);
    var pointLeft2 = Offset(pointLeft1.dx, pointLeft1.dy - lineVert);
    var pointRight1 = Offset(centerPoint.dx + lineHori, centerPoint.dy);
    var pointRight2 = Offset(pointRight1.dx, pointRight1.dy - lineVert);

    if (!isReverse) {
      centerPoint = Offset(startPoint.dx, startPoint.dy + lineVert);
      pointLeft1 = Offset(centerPoint.dx - lineHori, centerPoint.dy);
      pointLeft2 = Offset(pointLeft1.dx, pointLeft1.dy + lineVert);
      pointRight1 = Offset(centerPoint.dx + lineHori, centerPoint.dy);
      pointRight2 = Offset(pointRight1.dx, pointRight1.dy + lineVert);
    }

    // 中间到决赛
    canvas.drawLine(startPoint, centerPoint, paintCenter);

    // 中间到上半场
    canvas.drawLine(centerPoint, pointLeft1, paintLeft);
    canvas.drawLine(pointLeft1, pointLeft2, paintLeft);

    // 下半场到中间
    canvas.drawLine(centerPoint, pointRight1, paintRight);
    canvas.drawLine(pointRight1, pointRight2, paintRight);

    return (startPoint: startPoint, leftEndPoint: pointLeft2, rightEndPoint: pointRight2);
  }

  /// 上图下字
  ({ui.Offset startPoint, ui.Offset endPoint}) paintGameImageAndText(
    Canvas canvas,
    Size size, {
    required bool isReverse,
    required ui.Offset startPoint,
    required String text,
    required ui.Image? image,
  }) {
    // 图片高度(限制最大高度)
    double imgHeight = 30;
    double imgWidth = 30;
    double imgTextSpacing = 4;

    double factor = isReverse == true ? 1.0 : -1.0;

    // 文字绘制
    final textSpan = TextSpan(
      text: text,
      style: const TextStyle(
        color: Colors.white,
        fontSize: 11,
        backgroundColor: Colors.green,
      ),
    );
    final textPainter = TextPainter(
      text: textSpan,
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr,
    );
    textPainter.layout();

    // 5. 绘制到 Canvas
    var offset = Offset(
      startPoint.dx - textPainter.width / 2,
      startPoint.dy - (textPainter.height + imgTextSpacing) * factor,
    );

    if (!isReverse) {
      offset = Offset(
        startPoint.dx - textPainter.width / 2,
        startPoint.dy + imgTextSpacing + imgHeight,
      );
    }

    textPainter.paint(canvas, offset);

    // 总高度
    final totalHeight = imgHeight + imgTextSpacing * 2 + textPainter.height;

    final endPoint = Offset(startPoint.dx, startPoint.dy - totalHeight * factor);

    if (image != null) {
      // 绘制图片(居中)
      var imgRect = Rect.fromCenter(
        center: Offset(endPoint.dx, endPoint.dy + imgHeight / 2 * factor),
        width: imgWidth,
        height: imgHeight,
      );

      if (!isReverse) {
        imgRect = Rect.fromCenter(
          center: Offset(endPoint.dx, startPoint.dy + imgHeight / 2),
          width: imgWidth,
          height: imgHeight,
        );
      }

      final paintCenter = Paint()
        ..color = Colors.white38
        ..strokeWidth = 2
        ..style = PaintingStyle.stroke;
      canvas.drawRect(imgRect, paintCenter);

      paintImage(
        canvas: canvas,
        rect: imgRect,
        image: image!,
        fit: BoxFit.contain,
      );
    }
    return (startPoint: startPoint, endPoint: endPoint);
  }

  /// 绘制文字居中
  TextPainter customDrawTextCenter(
    Canvas canvas, {
    required Offset point,
    required String text,
    required TextStyle style,
  }) {
    // InlineSpan? text
    // 1. 定义文字内容与样式

    // 1. 创建 TextPainter
    final textPainter = TextPainter(
      text: TextSpan(text: text, style: style),
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr,
    );
    // 3. 排版
    textPainter.layout();
    // 4. 计算绘制位置(居中)
    final offset = Offset(
      point.dx - textPainter.width / 2,
      point.dy - textPainter.height / 2,
    );

    // 5. 绘制到 Canvas
    textPainter.paint(canvas, offset);
    return textPainter;
  }
}

最后、总结

核心思路是顶部从决赛反向绘制。每场比赛对局视图有三个关键点:一个顶点和两个底点。循环依赖实现。

github

相关推荐
来来走走8 小时前
Flutter MVVM+provider的基本示例
android·flutter
再学一点就睡9 小时前
初探 React Router:为手写路由筑牢基础
前端·react.js
悟空聊架构9 小时前
5 分钟上手!Burp 插件「瞎越」一键批量挖垂直越权
前端
炒毛豆9 小时前
vue3+antd实现华为云OBS文件拖拽上传详解
开发语言·前端·javascript
Pu_Nine_99 小时前
Axios 实例配置指南
前端·笔记·typescript·axios
红尘客栈210 小时前
Shell 编程入门指南:从基础到实战2
前端·chrome
前端大卫11 小时前
Vue 和 React 受控组件的区别!
前端
Hy行者勇哥11 小时前
前端代码结构详解
前端
练习时长一年11 小时前
Spring代理的特点
java·前端·spring
水星记_12 小时前
时间轴组件开发:实现灵活的时间范围选择
前端·vue