Drawing a Tournament Bracket in Flutter: Geometry, CustomPainter, and Team Selection
A tournament bracket looks like a grid until you try to build one. Teams occupy different vertical positions in each round. Two cards converge into one, the right side mirrors the left, and the finalists meet above a champion card. Lines, labels, flags, and tap targets all need to agree about where each card lives.
BracketIt’s Flutter play screen handles this with a geometry generator and separate painters. This article walks through the actual implementation, includes the complete source of the four files, and explains both its useful design choices and its unfinished behavior. This is a review of the existing code, not a rewritten bracket library or a claim that the Flutter application was run for this article.
Download the source files discussed here. The archive includes the four original files and supporting source excerpts; it is not a standalone Flutter application.
The architecture#
| File | Responsibility |
|---|---|
grid_geometry_generator.dart | Calculate each card’s position, size, and grid identity. |
link_painter.dart | Draw the connections between successive rounds. |
grid_painter.dart | Draw cards, team codes, flags, selection, and prediction decorations. |
field_painter.dart | Draw a simple green playing-field background; currently unused. |
The surrounding play.dart owns the widget composition and tap handling. Provider state supplies selected teams and whether editing is allowed. The painters render that state; they do not decide match results or save predictions.
Flutter’s CustomPainter provides the drawing delegate, including paint and shouldRepaint. This implementation uses two painter layers for the active bracket: connections first, team cards second.
Provider state ────────────────┐
v
Canvas size → Geometry → Links + Team cards
|
└──────→ Rectangles used for tap detection
1. The geometry: a fixed bracket that scales#
The nested geometry list is indexed as boxes[column][row]. Each item contains a top-left offset, a size, a GridPosition, and a nullable team. Its rect getter turns the offset and size into a rectangle used by both rendering and interaction.
There are nine columns and 31 card positions:
| Column | Cards | Meaning |
|---|---|---|
| 0 | 8 | Left-side starting teams |
| 1 | 4 | Left-side round-of-16 winners |
| 2 | 2 | Left-side quarterfinal winners |
| 3 | 1 | Left finalist |
| 4 | 1 | Champion |
| 5 | 1 | Right finalist |
| 6 | 2 | Right-side quarterfinal winners |
| 7 | 4 | Right-side round-of-16 winners |
| 8 | 8 | Right-side starting teams |
These are team slots, not 31 separate matches. Sixteen starting teams produce fifteen winner slots over a single-elimination tournament.
The generator starts from a 640 × 324 reference layout. At that size every card measures 56 × 28. All card dimensions and positions scale uniformly with the available width:
scale = canvasWidth / 640
layoutHeight = 324 × scale
cardWidth = 56 × scale
cardHeight = 28 × scale
x = referenceX × scale
y = referenceY × scale
At a width of 320, the bracket’s calculated height is 162 and cards are 28 × 14. The supplied canvasSize.height is never read. Consequently, the enclosing widget must provide the expected aspect ratio or the content may leave unused space or extend beyond the available height. play.dart uses AspectRatio(aspectRatio: 1.975), which is close to 640 / 324.
The reference arrays are deliberately hand-positioned. They do not derive a tournament tree from a team count. This is responsive sizing of one fixed structure; supporting 8, 32, or irregular brackets requires changing the geometry and connection definitions.
grid_geometry_generator.dart#
Original path: lib/screens/playscreen/grid_geometry_generator.dart
import 'dart:ui';
import 'models/bracket_models.dart';
class GridGeometryGenerator {
static List<List<GridBoxGeometry>> generate(Size canvasSize) {
List<List<GridBoxGeometry>> bracketPositions = [];
//assume we have this resolution screen. we plot the brackets w.r.t to this. later it can be scaled according to the actual Canvas size
double refWidth = 640;
double refHeight = 324;
double canvasWidth = canvasSize.width;
double canvasHeight = (refHeight / refWidth) * canvasWidth;
double refBoxHeight = (28 / refHeight) * canvasHeight;
double refBoxWidth = refBoxHeight * 2;
var boxSize = Size(refBoxWidth, refBoxHeight);
//starting point of the column wrt to refWidth. left to right
final columnXPositions = [
8,
89.26,
170.46,
251.57,
292,
332.75,
413.85,
495.05,
576.25
];
//starting point of the rows w.r.t to refHeight. top to bottom for each column
final rowYPositions = [
[0, 38, 86, 124, 172, 210, 258, 296],
[19, 105, 191, 277],
[62, 234],
[148],
[214],
[148],
[62, 234],
[19, 105, 191, 277],
[0, 38, 86, 124, 172, 210, 258, 296],
];
columnXPositions.asMap().forEach((column, columnValue) {
// xOffset relative to canvas width
final xOffset = (columnValue / refWidth) * canvasWidth;
List<GridBoxGeometry> rows = [];
rowYPositions[column].asMap().forEach((row, rowValue) {
// yOffset relative to canvas height
final yOffset = rowValue / refHeight * canvasHeight;
final position = GridBoxGeometry(
Offset(
xOffset,
yOffset,
),
boxSize,
GridPosition(column, row),
null);
rows.add(position);
});
bracketPositions.add(rows);
});
return bracketPositions;
}
}
2. The links: two branches converge on a winner#
For a left-side match, the connection starts at each source card’s centerRight and ends at the next-round card’s centerLeft. A vertical junction sits halfway across the horizontal gap. Each source branch runs horizontally toward the junction, bends with quadraticBezierTo, and continues vertically to the winner’s centerline. The bend radius is 15% of the card height.
Team A ────╮
├──── Winner
Team B ────╯
The right side mirrors this: source cards connect from their left edges into a winner card to their left. Seven hardcoded triplets describe each side. The final connection joins the two finalists horizontally and drops to the champion’s top center, giving fifteen match connections altogether.
Before drawing, LinkPainter regenerates geometry from the paint size, replacing its own boxGeometryList reference. It then looks up selected teams with keys such as "0,0". This replacement does not update the parent’s geometry list; the two painters stay aligned because their geometry is calculated using the same layout assumptions.
What the code actually draws today#
drawPathAndCircle begins with if (1 == 1). That branch always executes and draws all three paths with linePaint, which is black with a stroke width of 2. A comment calls these paths yellow, but the paint value determines the actual color. All endpoint-circle calls in that active branch are commented out.
The inactive else contains a more ambitious result display: green for progression, red for elimination, and grey for teams that were not correctly predicted to participate. It examines the pool state, isCorrect, and team IDs. This behavior is present in source but does not run.
Simply removing the unconditional branch would not finish the feature. In the correct-winner branch, some draws use teamBPath where the comment describes the winner path, and later use winningTeamPath while classifying team B. Also, drawMidLink never assigns the champion’s team from selectedTeams; fresh generated geometry initializes it to null. The finalist slots happen to be populated by the earlier side-link passes, but the champion is not. These are code-review findings, not a verified replacement color algorithm.
link_painter.dart#
Original path: lib/screens/playscreen/painters/link_painter.dart
import 'package:flutter/material.dart';
import '../../../provider/tournament_details_provider/models/FetchedPredictionsModel.dart';
import '../../../utils/Logger.dart';
import '../grid_geometry_generator.dart';
import '../models/bracket_models.dart';
import '../utils/bracket_helper.dart';
class LinkPainter extends CustomPainter {
double circleDiameter = 2;
List<List<GridBoxGeometry>> boxGeometryList;
Map<String, BracketTeam> selectedTeams;
bool poolIsOpen;
Paint circlePaint = Paint()
..color = Colors.yellow
..strokeWidth = 2.5
..style = PaintingStyle.fill;
Paint linePaint = Paint()
..color = Colors.black
..strokeWidth = 2
..style = PaintingStyle.stroke;
//color for indicating that the team has progressed further in the bracket or not progressed
Paint matchWonCirclePaint = Paint()
..color = Colors.green
..strokeWidth = 2.5
..style = PaintingStyle.fill;
Paint matchLostCirclePaint = Paint()
..color = Colors.red
..strokeWidth = 2.5
..style = PaintingStyle.fill;
Paint didNotPlayCirclePaint = Paint()
..color = Colors.grey
..strokeWidth = 2.5
..style = PaintingStyle.fill;
Paint matchWonPathPaint = Paint()
..color = Colors.green
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
Paint matchLostPathPaint = Paint()
..color = Colors.red
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
Paint didNotPlayPathPaint = Paint()
..color = Colors.grey
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
LinkPainter(
{required this.boxGeometryList,
required this.selectedTeams,
required this.poolIsOpen});
@override
void paint(Canvas canvas, Size size) {
/*
Expectation:
____________
Number of columns = 9
Number of rows = 8
Todo : Make the links dynamic
*/
boxGeometryList = GridGeometryGenerator.generate(size);
//this array represents these boxes for the links to connect, for example:
// (0,0)-first
// (1,0)-third
// (0,1)-second
List<List<GridBoxGeometry>> forwardLinkedPositions = [
[boxGeometryList[0][0], boxGeometryList[0][1], boxGeometryList[1][0]],
[boxGeometryList[0][2], boxGeometryList[0][3], boxGeometryList[1][1]],
[boxGeometryList[0][4], boxGeometryList[0][5], boxGeometryList[1][2]],
[boxGeometryList[0][6], boxGeometryList[0][7], boxGeometryList[1][3]],
[boxGeometryList[1][0], boxGeometryList[1][1], boxGeometryList[2][0]],
[boxGeometryList[1][2], boxGeometryList[1][3], boxGeometryList[2][1]],
[boxGeometryList[2][0], boxGeometryList[2][1], boxGeometryList[3][0]],
];
//this array represents these boxes for the links to connect, for example:
// (0,0)-second
// (5,0)-first
// (0,1)-third
List<List<GridBoxGeometry>> reverseLinkedPosition = [
[boxGeometryList[5][0], boxGeometryList[6][0], boxGeometryList[6][1]],
[boxGeometryList[6][0], boxGeometryList[7][0], boxGeometryList[7][1]],
[boxGeometryList[6][1], boxGeometryList[7][2], boxGeometryList[7][3]],
[boxGeometryList[7][0], boxGeometryList[8][0], boxGeometryList[8][1]],
[boxGeometryList[7][1], boxGeometryList[8][2], boxGeometryList[8][3]],
[boxGeometryList[7][2], boxGeometryList[8][4], boxGeometryList[8][5]],
[boxGeometryList[7][3], boxGeometryList[8][6], boxGeometryList[8][7]],
];
drawForwardLinks(canvas, forwardLinkedPositions);
drawReverseLinks(canvas, reverseLinkedPosition);
drawMidLink(canvas, [
boxGeometryList[3][0],
boxGeometryList[4][0],
boxGeometryList[5][0]
]); //draw links for the final winner
}
void drawForwardLinks(Canvas canvas, List<List<GridBoxGeometry>> forward) {
for (List<GridBoxGeometry> link in forward) {
//get the data for the boxes that need to be connected
GridBoxGeometry teamA = link[0];
GridBoxGeometry teamB = link[1];
GridBoxGeometry winningTeam = link[2];
//assign team values to the boxes //to get if the isCorrect prediction value
teamA.team = selectedTeams[BracketHelper.convertGridColumnAndRowToMapKey(
column: link[0].position.column, row: link[0].position.row)];
// Logger.log(
// "Team A : ${link[0].position.column}, ${link[0].position.row} : id = ${teamA.team?.id} : isCorrect = ${teamA.team?.isCorrect}");
teamB.team = selectedTeams[BracketHelper.convertGridColumnAndRowToMapKey(
column: link[1].position.column, row: link[1].position.row)];
// Logger.log(
// "Team B : ${link[1].position.column}, ${link[1].position.row} : id = ${teamB.team?.id}: isCorrect = ${teamB.team?.isCorrect}");
winningTeam.team = selectedTeams[
BracketHelper.convertGridColumnAndRowToMapKey(
column: link[2].position.column, row: link[2].position.row)];
// Logger.log(
// "Winning Team : ${link[2].position.column}, ${link[2].position.row} : id = ${winningTeam.team?.id} : isCorrect = ${winningTeam.team?.isCorrect}");
// Logger.log("----------------------------------------------------------");
//the curve in the link starting from the teamA 2 left boxes to meet the third box
var curveRadius = teamA.size.height * 0.15;
//find the mid point of the ends of the boxes to start the links from
Offset teamAMidPoint = teamA.rect.centerRight;
Offset teamBMidPoint = teamB.rect.centerRight;
Offset winningTeamMidPoint = winningTeam.rect.centerLeft;
//mid point between the teamA/seoncd box and the third box
Offset midPointBetweenFirstAndThird = Offset(
(teamAMidPoint.dx + winningTeamMidPoint.dx) / 2,
(teamAMidPoint.dy + winningTeamMidPoint.dy) / 2);
//PATH A
//draw link from the third box up to the mid point
//teamA
// ---third
//teamB
var winningTeamPath = Path();
winningTeamPath.moveTo(winningTeamMidPoint.dx, winningTeamMidPoint.dy);
winningTeamPath.lineTo(
midPointBetweenFirstAndThird.dx, winningTeamMidPoint.dy);
//PATH B
//draw line from teamA box to meet PATH A
//teamA----,
// '---third
//teamB
var teamAPath = Path();
teamAPath.moveTo(teamAMidPoint.dx, teamAMidPoint.dy); //
teamAPath.lineTo(
//line before the curve begins
midPointBetweenFirstAndThird.dx - curveRadius,
teamAMidPoint.dy);
teamAPath.quadraticBezierTo(
midPointBetweenFirstAndThird.dx,
teamAMidPoint.dy, //draw the curve
midPointBetweenFirstAndThird.dx,
teamAMidPoint.dy + curveRadius);
teamAPath.lineTo(midPointBetweenFirstAndThird.dx,
winningTeamMidPoint.dy); //connect the line with with PATH A
//PATH C
//draw line from teamB box to meet PATH A
//teamA
// ,---third
//teamB---'
var teamBPath = Path();
teamBPath.moveTo(teamBMidPoint.dx, teamBMidPoint.dy);
teamBPath.lineTo(
//line before curve begins
midPointBetweenFirstAndThird.dx - curveRadius,
teamBMidPoint.dy);
teamBPath.quadraticBezierTo(
midPointBetweenFirstAndThird.dx,
teamBMidPoint.dy, //draw the curve
midPointBetweenFirstAndThird.dx,
teamBMidPoint.dy - curveRadius);
teamBPath.lineTo(midPointBetweenFirstAndThird.dx,
winningTeamMidPoint.dy); //connect the line with with PATH A
drawPathAndCircle(canvas,
winningTeam: winningTeam,
teamA: teamA,
teamB: teamB,
winningTeamPath: winningTeamPath,
teamAPath: teamAPath,
teamBPath: teamBPath,
winningTeamMidPoint: winningTeamMidPoint,
teamAMidPoint: teamAMidPoint,
teamBMidPoint: teamBMidPoint);
}
}
void drawReverseLinks(Canvas canvas, List<List<GridBoxGeometry>> reverse) {
for (List<GridBoxGeometry> link in reverse) {
GridBoxGeometry winningTeam = link[0];
GridBoxGeometry teamA = link[1];
GridBoxGeometry teamB = link[2];
//assign team values to the boxes //to get if the isCorrect prediction value
winningTeam.team = selectedTeams[
BracketHelper.convertGridColumnAndRowToMapKey(
column: link[0].position.column, row: link[0].position.row)];
// Logger.log(
// "Winning Team : ${link[0].position.column}, ${link[0].position.row} : id = ${winningTeam.team?.id} : isCorrect = ${winningTeam.team?.isCorrect}");
teamA.team = selectedTeams[BracketHelper.convertGridColumnAndRowToMapKey(
column: link[1].position.column, row: link[1].position.row)];
// Logger.log(
// "Team A : ${link[1].position.column}, ${link[1].position.row} : id = ${teamA.team?.id} : isCorrect = ${teamA.team?.isCorrect}");
teamB.team = selectedTeams[BracketHelper.convertGridColumnAndRowToMapKey(
column: link[2].position.column, row: link[2].position.row)];
// Logger.log(
// "Team B : ${link[2].position.column}, ${link[2].position.row} : id = ${teamB.team?.id} : isCorrect = ${teamB.team?.isCorrect}");
// Logger.log("----------------------------------------------------------");
var curveRadius = winningTeam.size.height * 0.15;
Offset winningTeamMidPoint = winningTeam.rect.centerRight;
Offset teamAMidPoint = teamA.rect.centerLeft;
Offset teamBMidPoint = teamB.rect.centerLeft;
Offset midPointBetweenFirstAndThird = Offset(
(winningTeamMidPoint.dx + teamBMidPoint.dx) / 2,
(winningTeamMidPoint.dy + teamBMidPoint.dy) / 2);
//PATH A
//draw link from the winningTeam box up to the mid point
// teamA
//winningTeam---
// teamB
var winningTeamPath = Path();
winningTeamPath.moveTo(winningTeamMidPoint.dx, winningTeamMidPoint.dy);
winningTeamPath.lineTo(
midPointBetweenFirstAndThird.dx, winningTeamMidPoint.dy);
//PATH B
//draw link from the teamA box to meet PATH A
// ,----teamA
//winningTeam---'
// teamB
var teamAPath = Path();
teamAPath.moveTo(teamAMidPoint.dx, teamAMidPoint.dy);
teamAPath.lineTo(
midPointBetweenFirstAndThird.dx + curveRadius, teamAMidPoint.dy);
teamAPath.quadraticBezierTo(
midPointBetweenFirstAndThird.dx,
teamAMidPoint.dy,
midPointBetweenFirstAndThird.dx,
teamAMidPoint.dy + curveRadius);
teamAPath.lineTo(midPointBetweenFirstAndThird.dx, winningTeamMidPoint.dy);
//PATH C
//draw link from the teamB box to meet PATH A
// teamA
//winningTeam---'
// '-----teamB
var teamBPath = Path();
teamBPath.moveTo(teamBMidPoint.dx, teamBMidPoint.dy);
teamBPath.lineTo(
midPointBetweenFirstAndThird.dx + curveRadius, teamBMidPoint.dy);
teamBPath.quadraticBezierTo(
midPointBetweenFirstAndThird.dx,
teamBMidPoint.dy,
midPointBetweenFirstAndThird.dx,
teamBMidPoint.dy - curveRadius);
teamBPath.lineTo(midPointBetweenFirstAndThird.dx, winningTeamMidPoint.dy);
drawPathAndCircle(canvas,
winningTeam: winningTeam,
teamA: teamA,
teamB: teamB,
winningTeamPath: winningTeamPath,
teamAPath: teamAPath,
teamBPath: teamBPath,
winningTeamMidPoint: winningTeamMidPoint,
teamAMidPoint: teamAMidPoint,
teamBMidPoint: teamBMidPoint);
}
}
void drawMidLink(Canvas canvas, List<GridBoxGeometry> link) {
//we need to draw:
// first-----,-----teamB
// |
// winningTeam
GridBoxGeometry teamA = link[0];
// Logger.log(
// "Team A: ${link[0].position.column}, ${link[0].position.row} : id = ${teamA.team?.id} : isCorrect = ${teamA.team?.isCorrect}");
GridBoxGeometry winningTeam = link[1];
// Logger.log(
// "Winning Team : ${link[1].position.column}, ${link[1].position.row} : id = ${winningTeam.team?.id} : isCorrect = ${winningTeam.team?.isCorrect}");
GridBoxGeometry teamB = link[2];
// Logger.log(
// "Team B: ${link[2].position.column}, ${link[2].position.row} : id = ${teamB.team?.id} : isCorrect = ${teamB.team?.isCorrect}");
// Logger.log("----------------------------------------------------------");
Offset teamAMidPoint = teamA.rect.centerRight;
Offset teamBMidPoint = teamB.rect.centerLeft;
Offset winningTeamMidPoint = winningTeam.rect.topCenter;
Offset midPointBetweenTeamAAndTeamB = Offset(
(teamAMidPoint.dx + teamBMidPoint.dx) / 2,
(teamAMidPoint.dy + teamBMidPoint.dy) / 2);
//PATH A
// first----- teamB
//
// winningTeam
var teamAPath = Path();
teamAPath.moveTo(teamAMidPoint.dx, teamAMidPoint.dy);
teamAPath.lineTo(midPointBetweenTeamAAndTeamB.dx, teamAMidPoint.dy);
// canvas.drawPath(teamAPath, linePaint);
//PATH B
// first -----teamB
//
// winningTeam
var teamBPath = Path();
teamBPath.moveTo(teamBMidPoint.dx, teamBMidPoint.dy);
teamBPath.lineTo(midPointBetweenTeamAAndTeamB.dx, teamBMidPoint.dy);
// canvas.drawPath(teamBPath, linePaint);
//PATH C
// first , teamB
// |
// winningTeam
var winningTeamPath = Path();
winningTeamPath.moveTo(winningTeamMidPoint.dx, winningTeamMidPoint.dy);
winningTeamPath.lineTo(
midPointBetweenTeamAAndTeamB.dx, midPointBetweenTeamAAndTeamB.dy);
drawPathAndCircle(canvas,
winningTeam: winningTeam,
teamA: teamA,
teamB: teamB,
winningTeamPath: winningTeamPath,
teamAPath: teamAPath,
teamBPath: teamBPath,
winningTeamMidPoint: winningTeamMidPoint,
teamAMidPoint: teamAMidPoint,
teamBMidPoint: teamBMidPoint);
}
void drawPathAndCircle(Canvas canvas,
{required GridBoxGeometry winningTeam,
required GridBoxGeometry teamA,
required GridBoxGeometry teamB,
required Path winningTeamPath,
required Path teamAPath,
required Path teamBPath,
required Offset winningTeamMidPoint,
required Offset teamAMidPoint,
required Offset teamBMidPoint}) {
if(1==1){
//yellow color for all paths
canvas.drawPath(teamAPath, linePaint);
//canvas.drawCircle(teamAMidPoint, circleDiameter, circlePaint);
canvas.drawPath(teamBPath, linePaint);
// canvas.drawCircle(teamBMidPoint, circleDiameter, circlePaint);
canvas.drawPath(winningTeamPath, linePaint);
//canvas.drawCircle(winningTeamMidPoint, circleDiameter, circlePaint);
}else {
//logic to draw and path to color them accordingly if the teams progressed/didn't progress
if (poolIsOpen) {
canvas.drawPath(winningTeamPath, linePaint);
canvas.drawCircle(winningTeamMidPoint, circleDiameter, circlePaint);
canvas.drawPath(teamAPath, linePaint);
canvas.drawCircle(teamAMidPoint, circleDiameter, circlePaint);
canvas.drawPath(teamBPath, linePaint);
canvas.drawCircle(teamBMidPoint, circleDiameter, circlePaint);
} else {
//CASE: THE PREDICTION FOR THE WINNER WAS CORRECT
if (winningTeam.team?.isCorrect == true) {
//the path for winning team is ALWAYS green as either teamA or teamB has progressed
canvas.drawPath(teamBPath, matchWonPathPaint);
canvas.drawCircle(
winningTeamMidPoint, circleDiameter, matchWonCirclePaint);
if (teamA.team?.isCorrect == true) {
if (teamA.team?.id == winningTeam.team?.id) {
//teamA was correctly predicted as the winner
canvas.drawPath(teamAPath, matchWonPathPaint);
canvas.drawCircle(
teamAMidPoint, circleDiameter, matchWonCirclePaint);
} else {
//teamA was correctly predicted as the loser
canvas.drawPath(teamAPath, matchLostPathPaint);
canvas.drawCircle(
teamAMidPoint, circleDiameter, matchLostCirclePaint);
}
} else {
//prediction for teamA was wrong to begin with and did not play the match
canvas.drawPath(teamAPath, didNotPlayPathPaint);
canvas.drawCircle(
teamAMidPoint, circleDiameter, didNotPlayCirclePaint);
}
if (teamB.team?.isCorrect == true) {
if (teamB.team?.id == winningTeam.team?.id) {
//teamB was correctly predicted as the winner
canvas.drawPath(winningTeamPath, matchWonPathPaint);
canvas.drawCircle(
teamBMidPoint, circleDiameter, matchWonCirclePaint);
} else {
//teamB was correctly predicted as the loser
canvas.drawPath(winningTeamPath, matchLostPathPaint);
canvas.drawCircle(
teamBMidPoint, circleDiameter, matchLostCirclePaint);
}
} else {
//prediction for teamB was wrong to begin with and did not play the match
canvas.drawPath(winningTeamPath, didNotPlayPathPaint);
canvas.drawCircle(
teamBMidPoint, circleDiameter, didNotPlayCirclePaint);
}
} //CASE: THE PREDICTION FOR THE WINNER WAS NOT CORRECT
else {
//the path for winning team can be GREY or GREEN or RED. 1. GREY: if both predictions for teamA and teamB was wrong to begin with, so neither of then
//progressed 2. GREEN: one of teamA or teamB progressed (but was not correctly predicted as the winner of course)
//2. RED: eg. teamA was correctly predicted but teamB was not. so teamA line is red and teamB line is grey. so we put winnerTeam line
//as red as well
if (teamA.team?.isCorrect == false &&
teamB.team?.isCorrect == false) {
canvas.drawPath(winningTeamPath, didNotPlayPathPaint);
canvas.drawCircle(
winningTeamMidPoint, circleDiameter, didNotPlayCirclePaint);
} else if (teamA.team?.id == winningTeam.team?.id || teamB.team?.id
== winningTeam.team?.id) {
canvas.drawPath(winningTeamPath, matchLostPathPaint);
canvas.drawCircle(
winningTeamMidPoint, circleDiameter, matchLostCirclePaint);
} else {
canvas.drawPath(winningTeamPath, matchWonPathPaint);
canvas.drawCircle(
winningTeamMidPoint, circleDiameter, matchWonCirclePaint);
}
if (teamA.team?.isCorrect == true) {
if (teamA.team?.id == winningTeam.team?.id) {
//teamA was incorrectly picked as the winner
canvas.drawPath(teamAPath, matchLostPathPaint);
canvas.drawCircle(
teamAMidPoint, circleDiameter, matchLostCirclePaint);
} else {
//teamA was the winner but was not picked as so
canvas.drawPath(teamAPath, matchWonPathPaint);
canvas.drawCircle(
teamAMidPoint, circleDiameter, matchWonCirclePaint);
}
} else {
//prediction for teamA was wrong to begin with and did not play the match
canvas.drawPath(teamAPath, didNotPlayPathPaint);
canvas.drawCircle(
teamAMidPoint, circleDiameter, didNotPlayCirclePaint);
}
if (teamB.team?.isCorrect == true) {
if (teamB.team?.id == winningTeam.team?.id) {
//teamB was incorrectly picked as the winner
canvas.drawPath(teamBPath, matchLostPathPaint);
canvas.drawCircle(
teamBMidPoint, circleDiameter, matchLostCirclePaint);
} else {
//teamB was the winner but was not picked as so
canvas.drawPath(teamBPath, matchWonPathPaint);
canvas.drawCircle(
teamBMidPoint, circleDiameter, matchWonCirclePaint);
}
} else {
//prediction for teamB was wrong to begin with and did not play the match
canvas.drawPath(teamBPath, didNotPlayPathPaint);
canvas.drawCircle(
teamBMidPoint, circleDiameter, didNotPlayCirclePaint);
}
}
}
}
}
Rect getRectForPoint(Offset point, Size size) {
return Rect.fromLTWH(point.dx, point.dy, size.width, size.height);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
}
3. The cards: team data, selection, and prediction feedback#
GridPainter iterates through every geometry item and assigns its team from selectedTeams["column,row"]. It draws a rounded rectangle with corner radius equal to 20% of the card height, preceded by a shadow.
The background priority is explicit:
- A selected card gets a grey background.
- Otherwise, a correct prediction gets a blue gradient when the pool is closed.
- Otherwise, the card gets a pale blue background.
The text uses the team’s short code, or an empty string for an unfilled position. Correct predictions in closed pools use white text with a shadow; other text is dark blue. Tournament ID 1 uses a font size of half the card height, while other IDs use 45%.
Flag images come from a separate map using the same coordinate keys. If a flag is unavailable, the painter uses flagPlaceholder when supplied. Left-side flags sit partly outside the left edge, right-side flags partly outside the right edge, and the champion flag sits below its card. These decorations can extend beyond the rectangles used for hit testing.
Once editing closes, correct predictions receive check marks on the outer upper corners. The champion instead receives a crown above the card. A trophy is painted near the canvas center before the cards. Its center includes a fixed 10-pixel upward offset, unlike the width-scaled geometry.
The tournamentId branches for flag size and horizontal offset currently return identical values on both sides of each conditional, so those branches do not change flag placement.
grid_painter.dart#
Original path: lib/screens/playscreen/painters/grid_painter.dart
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../../provider/tournament_details_provider/models/FetchedPredictionsModel.dart';
import '../models/bracket_models.dart';
import '../utils/bracket_helper.dart';
class GridPainter extends CustomPainter {
GridPosition? selectedPosition;
bool poolIsOpen;
List<List<GridBoxGeometry>> boxGeometryList;
Map<String, ui.Image> flagsList;
Map<String, BracketTeam> selectedTeams;
int tournamentId;
ui.Image? correctPredictionIcon;
ui.Image? crownIcon;
ui.Image? trophyIcon;
ui.Image? flagPlaceholder;
GridPainter(
{required this.boxGeometryList,
required this.selectedPosition,
required this.flagsList,
required this.selectedTeams,
required this.poolIsOpen,
required this.tournamentId,
required this.correctPredictionIcon,
required this.crownIcon,
required this.trophyIcon,
required this.flagPlaceholder});
@override
void paint(Canvas canvas, Size size) {
//draw the center trophy
if (trophyIcon != null) {
Rect trophyRect = Rect.fromCenter(
center: Offset(size.width / 2, size.height / 2 - 10),
width: size.width / 10,
height: size.height / 5);
paintImage(canvas: canvas, rect: trophyRect, image: trophyIcon!);
}
boxGeometryList.asMap().forEach((column, rowItems) {
rowItems.asMap().forEach(
(row, geometryItem) {
Rect targetRect = geometryItem.rect;
geometryItem.team = selectedTeams[
BracketHelper.convertGridColumnAndRowToMapKey(
column: column, row: row)];
var radius = ui.Radius.circular(geometryItem.size.height * 0.2);
var roundedRect = RRect.fromRectAndRadius(targetRect, radius);
var boxPaint = Paint()
..color = const Color.fromRGBO(236, 250, 255, 1)
..style = PaintingStyle.fill;
var selectedBoxPaint = Paint()
..color = Colors.grey.shade300
..style = PaintingStyle.fill;
var correctPredictionBoxPaint = Paint()
..shader = ui.Gradient.linear(
targetRect.topLeft,
targetRect.bottomRight,
[
const Color(0xff49A8FF),
const Color(0xff0017E2),
],
);
//PAINT THE GRID BOX
//draw a shadow of the rect first and only paint the rect over it to give elevation effect
canvas.drawShadow(
Path()..addRRect(roundedRect), Colors.black, 4, false);
//highlight the box when user taps it
if (selectedPosition != null &&
selectedPosition!.column == column &&
selectedPosition!.row == row) {
canvas.drawRRect(roundedRect, selectedBoxPaint);
//color blue for correct predictions
} else if (geometryItem.team?.isCorrect == true && !poolIsOpen) {
canvas.drawRRect(roundedRect, correctPredictionBoxPaint);
} else {
canvas.drawRRect(roundedRect, boxPaint);
}
//paint the team name
var painter = getText(
geometryItem.size,
geometryItem.team?.code ?? "",
geometryItem.team?.isCorrect ?? false,
);
canvas.drawTextInRect(targetRect, Alignment.center, painter);
Size flagSize = tournamentId == 1
? Size(geometryItem.size.height / 1.7,
geometryItem.size.height / 1.7)
: Size(geometryItem.size.height / 1.7,
geometryItem.size.height / 1.7);
var selectedCountryFlag = flagsList[
BracketHelper.convertGridColumnAndRowToMapKey(
column: column, row: row)];
//paint the flag
if (selectedCountryFlag != null || flagPlaceholder != null) {
var tempRect = Rect.zero;
var offsetRect = Rect.zero;
if (column < 4) {
// Flag Image should be on left
tempRect = Rect.fromLTWH(
tournamentId == 1
? targetRect.left - geometryItem.size.width * 0.14
: targetRect.left - geometryItem.size.width * 0.14,
targetRect.top,
targetRect.width,
targetRect.height);
offsetRect = Alignment.centerLeft.inscribe(flagSize, tempRect);
} else if (column == 4) {
// Flag Image should be on bottom
tempRect = Rect.fromLTWH(
targetRect.left,
targetRect.top + geometryItem.size.height * 0.3,
targetRect.width,
targetRect.height);
offsetRect = Alignment.bottomCenter.inscribe(flagSize, tempRect);
} else {
// Flag image should be on right
tempRect = Rect.fromLTWH(
tournamentId == 1
? targetRect.left + geometryItem.size.width * 0.14
: targetRect.left + geometryItem.size.width * 0.14,
targetRect.top,
targetRect.width,
targetRect.height);
offsetRect = Alignment.centerRight.inscribe(flagSize, tempRect);
}
paintImage(
canvas: canvas,
rect: offsetRect,
image: selectedCountryFlag ?? flagPlaceholder!);
}
Size checkIconSize = Size(
geometryItem.size.height / 2.2, geometryItem.size.height / 2.2);
//paint correct prediction icon
if (geometryItem.team?.isCorrect == true && !poolIsOpen) {
var tempCorrectIconRect = Rect.zero;
var offsetCorrectIconRect = Rect.zero;
if (correctPredictionIcon != null) {
if (column < 4) {
//check mark on top right
tempCorrectIconRect = Rect.fromLTWH(
targetRect.left + checkIconSize.width * 0.3,
targetRect.top - checkIconSize.height * 0.2,
targetRect.width,
targetRect.height);
offsetCorrectIconRect = Alignment.topRight
.inscribe(checkIconSize, tempCorrectIconRect);
} else if (column > 4) {
//check mark on top left
tempCorrectIconRect = Rect.fromLTWH(
targetRect.left - checkIconSize.width * 0.3,
targetRect.top - checkIconSize.height * 0.2,
targetRect.width,
targetRect.height);
offsetCorrectIconRect = Alignment.topLeft
.inscribe(checkIconSize, tempCorrectIconRect);
}
paintImage(
canvas: canvas,
rect: offsetCorrectIconRect,
image: correctPredictionIcon!);
}
if (crownIcon != null) {
if (column == 4) {
//check mark in the top center
checkIconSize = Size(
geometryItem.size.height / 1.5, geometryItem.size.height / 1.5);
tempCorrectIconRect = Rect.fromLTWH(
targetRect.left,
targetRect.top - geometryItem.size.height * 0.45,
targetRect.width,
targetRect.height);
offsetCorrectIconRect = Alignment.topCenter
.inscribe(checkIconSize, tempCorrectIconRect);
paintImage(
canvas: canvas,
rect: offsetCorrectIconRect,
image: crownIcon!);
}
}
}
},
);
});
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
TextPainter getText(Size boxSize, String text, bool isCorrectPrediction) {
final textStyle = TextStyle(
color: (isCorrectPrediction && !poolIsOpen) ? Colors.white : const Color(0xff001791),
shadows: isCorrectPrediction && !poolIsOpen
? [
const Shadow(
color: Colors.black, offset: Offset(1, 1), blurRadius: 2)
]
: null,
fontSize:
tournamentId == 1 ? boxSize.height * 0.5 : boxSize.height * 0.45,
fontWeight: FontWeight.bold);
final textSpan = TextSpan(
text: text,
style: textStyle,
);
final textPainter = TextPainter(
text: textSpan,
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
textPainter.layout(
minWidth: boxSize.width,
maxWidth: boxSize.width,
);
return textPainter;
}
}
extension CanvasExt on Canvas {
void drawTextInRect(
Rect rect,
Alignment alignment,
TextPainter painter,
) {
final offsetRect = alignment.inscribe(painter.size, rect);
painter.paint(this, offsetRect.topLeft);
}
}
4. The field: a background painter that is not connected#
FieldPainter fills the canvas with green, outlines the boundary in translucent white, and draws a horizontal line through the middle. It is a minimal field motif, without goals, penalty areas, or a center circle.
There is no instantiation of FieldPainter in the current lib source. The active play-screen stack contains LinkPainter and GridPainter, so this field is not currently visible. To use it, insert it as a background layer before those painters.
Its shouldRepaint returns false because it has no changing input fields. This means a new equivalent delegate does not itself request repainting; it does not mean Flutter can never repaint the drawing, for example after a size change.
field_painter.dart#
Original path: lib/screens/playscreen/painters/field_painter.dart
import 'package:flutter/material.dart';
class FieldPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
var margins = const Offset(0, 0);
var fieldPaint = Paint()
..color = Colors.green.shade600
..strokeWidth = 5.0
..strokeCap = StrokeCap.round
..style = PaintingStyle.fill;
final Rect rect = margins & size;
canvas.drawRect(rect, fieldPaint);
var paint = Paint()
..color = Colors.white.withOpacity(0.3)
..strokeWidth = 5.0
..style = PaintingStyle.stroke;
Offset startingPoint = Offset(0, size.height / 2);
Offset endingPoint = Offset(size.width, size.height / 2);
canvas.drawLine(startingPoint, endingPoint, paint);
// var fs = applyBoxFit(BoxFit.contain, const Size(250, 200), size);
// var rect1 = Alignment.center.inscribe(fs.destination, rect);
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
// var rect1 = Rect.fromLTWH(100, 100, 200, 200);
// canvas.drawRect(rect1, paint);
// var rect2 = Alignment.center.inscribe(Size(100, 100), rect1);
// canvas.drawRect(rect2, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}
5. Supporting types and coordinate keys#
The geometry model is small enough to share between layout, drawing, and tapping. Team information is mutable on each geometry item, which is convenient here but makes painting also responsible for updating data that interaction later reads.
bracket_models.dart#
Original path: lib/screens/playscreen/models/bracket_models.dart
import 'dart:ui';
import 'package:flutter/material.dart';
import '../../../provider/tournament_details_provider/models/FetchedPredictionsModel.dart';
class GridPosition {
final int column;
final int row;
GridPosition(this.column, this.row);
}
class GridBoxGeometry {
Offset topLeft;
Rect get rect =>
Rect.fromLTWH(topLeft.dx, topLeft.dy, size.width, size.height);
Size size;
GridPosition position;
BracketTeam? team;
GridBoxGeometry(this.topLeft, this.size, this.position, this.team);
}
The helper names the slots, maps columns to rounds, and defines the coordinate-key format. Its convertMapKeyToColumnOnly and convertMapKeyToRowOnly parse single character positions, which works for this nine-column layout but would need changing for multi-digit indices. Splitting on the comma would support larger coordinates.
bracket_helper.dart#
Original path: lib/screens/playscreen/utils/bracket_helper.dart
import 'package:bracketit/provider/tournament_details_provider/models/tournament.dart';
class BracketHelper {
static final _gridToBracketMap = <String, String>{
//left most bracket
"0,0": "Group A Winner",
"0,1": "Group B Runner-up",
"0,2": "Group C Winner",
"0,3": "Group D Runner-up",
"0,4": "Group E Winner",
"0,5": "Group F Runner-up",
"0,6": "Group G Winner",
"0,7": "Group H Runner-up",
//left most quarter final bracket
"1,0": "Round of 16 Winner",
"1,1": "Round of 16 Winner",
"1,2": "Round of 16 Winner",
"1,3": "Round of 16 Winner",
//left most semi final bracket
"2,0": "Quarter-finals Winner",
"2,1": "Quarter-finals Winner",
//left most final bracket
"3,0": "Semi-finals Winner",
//right most bracket
"8,0": "Group B Winner",
"8,1": "Group A Runner-up",
"8,2": "Group D Winner",
"8,3": "Group C Runner-up",
"8,4": "Group F Winner",
"8,5": "Group E runner-up",
"8,6": "Group H Winner",
"8,7": "Group G Runner-up",
//right most quarter final bracket
"7,0": "Round of 16 Winner",
"7,1": "Round of 16 Winner",
"7,2": "Round of 16 Winner",
"7,3": "Round of 16 Winner",
//right most semi final bracket
"6,0": "Quarter-finals Winner",
"6,1": "Quarter-finals Winner",
//right most final bracket
"5,0": "Semi-finals Winner",
//winner bracket
"4,0": "the Champion",
};
static String getBracketNames({required int column, required int row, required int tournamentId}) {
return tournamentId ==1 ? _gridToBracketMap[
convertGridColumnAndRowToMapKey(column: column, row: row)] ??
"N/A" : "winner";
}
static String getRoundName(
{required int column, required List<Round> round}) {
if (column == 0 || column == 8) {
return round[0].name;
} else if (column == 1 || column == 7) {
return round[1].name;
} else if (column == 2 || column == 6) {
return round[2].name;
} else if (column == 3 || column == 5) {
return round[3].name;
} else if (column == 4) {
return round[4].name;
} else {
return "";
}
}
static int getRoundId({required int column, required List<Round> rounds}) {
if (column == 0 || column == 8) {
return rounds[0].id;
} else if (column == 1 || column == 7) {
return rounds[1].id;
} else if (column == 2 || column == 6) {
return rounds[2].id;
} else if (column == 3 || column == 5) {
return rounds[3].id;
} else if (column == 4) {
return rounds[4].id;
} else {
return 0;
}
}
static String convertGridColumnAndRowToMapKey(
{required int column, required int row}) {
return "$column,$row";
}
static int convertMapKeyToColumnOnly({required String key}) {
return int.parse(key[0]);
}
static int convertMapKeyToRowOnly({required String key}) {
return int.parse(key[2]);
}
}
BracketTeam carries both team identity and prediction correctness. Below is the original prediction-model file. Its Team dependency comes from the application’s tournament model; the painters themselves do not fetch results or calculate isCorrect.
FetchedPredictionsModel.dart#
Original path: lib/provider/tournament_details_provider/models/FetchedPredictionsModel.dart
import 'package:bracketit/provider/tournament_details_provider/models/tournament.dart';
class FetchedPredictionMap {
List<FetchedPredictionData> data;
int poolId;
String userId;
bool editable;
FetchedPredictionMap({
required this.poolId,
required this.userId,
required this.data,
required this.editable,
});
factory FetchedPredictionMap.fromJson(Map<dynamic, dynamic> json) =>
FetchedPredictionMap(
poolId: json["poolId"],
userId: json["userId"],
editable: json['editable'],
data: List<FetchedPredictionData>.from(json["data"]
.map((x) =>
FetchedPredictionData.fromJson(x as Map<String, dynamic>))
.toList()),
);
}
class FetchedPredictionData {
final int roundId;
final List<BracketTeam> values;
FetchedPredictionData({
required this.roundId,
required this.values,
});
factory FetchedPredictionData.fromJson(Map<String, dynamic> json) =>
FetchedPredictionData(
roundId: json["roundId"],
values: List<BracketTeam>.from(json["values"]
.map((x) => BracketTeam.fromJson(x as Map<String, dynamic>))
.toList()),
);
}
//Same like the Team class but with additional properties tailored for bracket such as position and if prediction isCorrect when retrieving
//the predictions from api
class BracketTeam {
final int id;
final String name;
final String code;
final String group;
final String position;
final bool isCorrect;
final String imageLogo;
BracketTeam({
required this.id,
required this.name,
required this.code,
required this.group,
required this.position,
required this.isCorrect,
required this.imageLogo
});
factory BracketTeam.fromJson(Map<String, dynamic> json) => BracketTeam(
id: json["id"],
name: json["name"],
code: json["code"],
group: json["group"],
isCorrect: json["isCorrect"],
position: json["position"],
imageLogo: json["imageLogo"]?.toString().trim() ?? ""
);
//only use this constructor to show the team as an option when user is making predictions
factory BracketTeam.fromTeam(Team team) => BracketTeam(
id: team.id,
name: team.name,
code: team.code,
group: team.code,
isCorrect: false, //false as if we're still making predictions, the match hasn't happened yet
position: "", imageLogo: team.imageLogo); //postion does not matter
}
6. How the play screen composes the painters#
The following is the bracket widget block from play.dart. It is an integration excerpt, not a standalone widget: the provider objects, loaded images, size wrapper, state fields, and callback methods belong to the surrounding application.
InteractiveViewer supplies pan and zoom around the child. The size callback regenerates the parent geometry, Listener receives pointer-down events, and the Stack paints connections below the team cards.
var brackets = Padding(
padding: const EdgeInsets.only(top: 10.0),
child: Stack(
children: [
InteractiveViewer(
child: Align(
alignment: Alignment.center,
child: WidgetSizeOffsetWrapper(
onSizeChange: (size) {
setBoxGrid(size);
},
child: AspectRatio(
aspectRatio: 1.975,
child: Listener(
onPointerDown: dummyPlayScreen
? null
: (e) async {
await onTappedGridBox(e);
},
child: Stack(
alignment: Alignment.center,
children: <Widget>[
CustomPaint(
painter: LinkPainter(
poolIsOpen: _playScreenProvider.editable,
selectedTeams:
_playScreenProvider.selectedTeams,
boxGeometryList: boxGeometryList),
child: Container(),
),
CustomPaint(
painter: GridPainter(
tournamentId: widget.args.data?.tournamentId ?? 1,
boxGeometryList: boxGeometryList,
poolIsOpen: _playScreenProvider.editable,
flagsList: flagsList,
selectedTeams: _playScreenProvider.selectedTeams,
selectedPosition: selectedPosition,
correctPredictionIcon:
Provider.of<PlayScreenProvider>(context,
listen: true)
.correctPredictionIcon,
crownIcon: Provider.of<PlayScreenProvider>(
context,
listen: true)
.crownIcon,
trophyIcon: Provider.of<PlayScreenProvider>(
context,
listen: true)
.trophyIcon,
flagPlaceholder: Provider.of<PlayScreenProvider>(
context,
listen: true)
.flagPlaceholder,
),
child: Container(),
),
],
),
)),
),
),
),
],
),
);
Taps use the same rectangles as painting#
The screen flattens the geometry list and finds the rectangle containing details.localPosition. This is manual hit testing against card geometry; the painters do not attach a separate Flutter button to each card.
final hits = boxGeometryList
.expand((column) => column)
.where((box) => box.rect.contains(details.localPosition));
if (hits.isNotEmpty) {
final position = hits.first.position;
// Use position.column and position.row to identify the selection.
}
This abbreviated excerpt illustrates the existing lookup. The full handler also prevents editing while loading, while viewing another user’s bracket, or after editing closes. For later rounds it asks the provider for the two prerequisite selections and refuses to open team selection if either is absent. Selected positions trigger grey highlighting, and dismissal clears the highlight.
The listener is inside the transformed bracket subtree, so the lookup uses local coordinates. If pointer handling is moved outside that subtree, convert coordinates into the bracket’s coordinate system before testing rectangles.
The distinction matters: geometry identifies which card was tapped; the provider decides which teams are valid choices. No line painted on the canvas automatically enforces tournament rules.
7. Is painting a good way to build any dynamic UI?#
It is a useful approach for diagrams, charts, game boards, custom backgrounds, and visuals where many elements share a coordinate system. Geometry can be generated from data, and rendering can remain a straightforward pass over that geometry.
However, painting is not a replacement for a general widget system. Cards drawn on a canvas do not automatically become individually focusable controls or accessible buttons. Text input, native control behavior, keyboard navigation, semantic labels, and gesture handling require additional work. Rendering everything manually is also not automatically faster; profile the actual interface before making that claim.
For this bracket, a practical alternative is a hybrid: paint the links and use positioned Flutter widgets for team cards. Both layers can consume the same geometry. The cards can then use ordinary buttons and semantic labels, while the painter retains precise control over connections.
To make the bracket topology truly dynamic, represent matches explicitly with IDs and source-match references. Generate card positions from rounds and matches, then derive every connector from that same graph. Supporting a third-place match, byes, or double elimination needs additional topology and layout rules; changing a team-count variable alone is insufficient.
8. What I would improve next#
- One geometry calculation: pass the same layout result to cards, links, and interaction instead of regenerating it inside
LinkPainter. - Explicit connection data: replace the hardcoded triplet arrays with matches and incoming edges generated from tournament data.
- Separate data from geometry: keep layout objects immutable and look up team state without mutating geometry while painting.
- Consistent sizing: derive all drawing from a shared reference transform, including stroke widths, trophy offset, and other fixed dimensions. Use containment logic if arbitrary canvas aspect ratios must be supported.
- Prediction rendering: assign the champion data, correct the path mix-ups, and verify representative result combinations before enabling colored links.
- Focused repaint decisions: both active painters return true from
shouldRepaint, so every replacement delegate requests a repaint. Compare meaningful immutable inputs or notify painting through a suitable listenable when necessary. Returning true does not by itself create a continuous animation loop. - Accessible interaction: expose named team-selection controls through widgets or explicit painter semantics, and provide adequate tap-target sizes at small scales.
These are proposed improvements. The original source listings above intentionally preserve the current implementation, including its inactive branch and comments.
9. The same architecture in SwiftUI#
SwiftUI supports the same separation. A plain Swift geometry generator can produce card rectangles and connector points. Canvas can draw paths and other visual content from that layout, while regular SwiftUI views handle interactive team cards. A custom Layout can arrange those views when positioning needs to respond to the match structure.
| Flutter implementation | SwiftUI approach |
|---|---|
Geometry generator with Offset, Size, Rect | Plain Swift layout data with CGPoint, CGSize, CGRect |
LinkPainter and Dart Path | Canvas and SwiftUI Path |
| Painted team boxes | SwiftUI card views positioned from shared geometry |
| Manual rectangle tap checks | Native Button actions on each card, or custom hit testing for a fully drawn canvas |
| Provider-driven selection | SwiftUI-observed model and selection state |
For an interactive bracket I would use Canvas for connections and SwiftUI views for the cards. That preserves precise geometry while making native interaction and accessibility easier. A fully painted Canvas remains an option for a dense visualization, but individual drawn elements need their own interaction and accessibility strategy.
The transferable design choice is the shared geometry: one layout calculation can drive drawing, view placement, and interaction in either framework.