flutter_bracket 0.1.0
flutter_bracket: ^0.1.0 copied to clipboard
Generic graph-driven elimination brackets with pan, zoom, custom cards and themes.
A generic Flutter renderer for single and double elimination tournaments. It consumes explicit winner/loser progression, lays out fixed-size match cards, and renders everything in one pannable, zoomable canvas.
Flutter 3.44+, Dart 3.12+. Runtime dependencies: Flutter SDK only. MIT licensed. Supports Android, iOS, web, Windows, macOS and Linux.

- Explicit winner and loser progression for single and double elimination.
- BYE, TBD, grand-final reset, custom cards and Material theming.
- Pan, pinch/wheel zoom, fit and focus-match viewport controls.
- Two-dimensional card virtualization and canvas overview for large brackets.
Install #
flutter pub add flutter_bracket
Basic usage #
import 'package:flutter/material.dart';
import 'package:flutter_bracket/flutter_bracket.dart';
const bracket = Bracket(
id: 'cup',
sections: [
BracketSection(
id: 'main',
type: BracketSectionType.main,
rounds: [
BracketRound(
id: 'final',
order: 0,
label: 'Final',
matches: [
BracketMatch(
id: 'm1',
order: 0,
status: BracketMatchStatus.running,
side1: BracketSide(
source: ParticipantSource(),
participant: BracketParticipant(id: 'a', displayName: 'Alex'),
),
side2: BracketSide(
source: ParticipantSource(),
participant: BracketParticipant(id: 'b', displayName: 'Sam'),
),
games: [BracketGameScore(side1: 12, side2: 10)],
),
],
),
],
),
],
);
// Inside a MaterialApp. BracketView requires finite width and height.
const SizedBox(height: 500, child: BracketView(bracket: bracket));
Use Expanded(child: BracketView(...)) inside a Column. An unbounded viewport
throws a descriptive FlutterError. Empty brackets are supported.
Explicit progression #
Declare progression on the source match, including its destination side:
winnerTo: const BracketTarget(
matchId: 'final',
position: BracketSidePosition.side1,
),
loserTo: const BracketTarget(
matchId: 'losers-round-1',
position: BracketSidePosition.side2,
),
The corresponding target side can be unresolved:
side1: const BracketSide(source: WinnerOfSource('semifinal-1')),
winnerTo and loserTo are the only source of edges. A WinnerOfSource or
LoserOfSource must match that incoming edge. It never creates an edge itself.
Once known, add participant to the side without replacing its source.
TbdSource and resolved participant sides also accept incoming edges.
ParticipantSourcerequires a participant;ByeSourcemust not have one.- BYE is a side state. The package does not infer advancement; when resolved,
keep
WinnerOfSourceon the downstream side and attach its participant. - Unknown slots use
TbdSource; missing referenced IDs are errors. - IDs for matches and sections are globally unique; round IDs are unique within their section. An occupied destination side cannot have another edge.
- Self-references, cycles, inconsistent references and duplicate membership
throw
FormatException, including in release builds. - Duplicate round orders and progression to an earlier/same round are debug warnings. Ties sort by ID, so repeated layout is deterministic.
- Lists and metadata are caller-owned: supply replacement models on updates;
the package does not mutate them.
participantsis an optional catalog; cards render the participant supplied on each side, without an ID lookup.
For example, after Player 3 vs BYE is resolved, preserve the graph source
while supplying the participant to display downstream:
BracketSide(
source: const WinnerOfSource('m2'),
participant: player3,
)
Rounds group cards into columns; the internal graph determines progression. Same-section downstream centers average their source centers. A collision pushes a card down by the configured gap; irregular graphs therefore need not preserve the exact midpoint. Normal elimination trees do.
Double elimination #
Use winners, losers and finals sections in the same Bracket. Winners are
above losers, with finals to the right. Loser edges cross section boundaries;
the library does not render three independent brackets.
Provide GF1 and GF2, with explicit targets, to show a reset. Omit GF2 to hide it. The tournament engine decides qualification and reset activation, not this UI. See the complete sample models.

Cards, labels, selection and theme #
BracketView(
bracket: bracket,
selectedMatchId: selectedId,
highlightedMatchIds: const {'m1', 'm5'},
onMatchTap: (match) => setState(() => selectedId = match.id),
layoutConfig: const BracketLayoutConfig(matchWidth: 300, matchHeight: 120),
theme: const BracketTheme(
winnerConnectorColor: Colors.teal,
loserConnectorColor: Colors.orange,
borderRadius: 12,
),
sourceLabelBuilder: (source) => switch (source) {
ByeSource() => 'BYE',
WinnerOfSource(:final matchId) => 'Winner of $matchId',
LoserOfSource(:final matchId) => 'Loser of $matchId',
_ => 'TBD',
},
matchBuilder: (context, match, state) => MyMatchCard(
match: match,
selected: state.isSelected,
highlighted: state.isHighlighted,
),
)
Omit matchBuilder for the default accessible card: names, seeds, game scores,
winner check, running dot and unresolved labels. It uses the surrounding
Material theme for light/dark colors. onMatchTap makes default cards buttons
with keyboard activation. Semantics include both names, status and scores.
Custom builders receive tight matchWidth × matchHeight constraints and own
their content, overflow, keyboard behavior and semantics. Avoid child tap
handlers if you want the view's onMatchTap handler to receive the gesture.
Incoming connectors meet the card at 25%/75% of its height. Outgoing connectors
start at its right center.
Selection is controlled by the caller. Highlighting styles explicit match IDs; participant-path highlighting is not implemented. All default unresolved labels are English; use the formatter or a custom card for localization.
BracketTheme styles colors, text, card radius and connector width.
BracketLayoutConfig owns dimensions and spacing (defaults: card 280×120,
round gap 96, match gap 32, section gap 120, header height 48).
Set header height to zero to hide headers. Non-finite/negative dimensions and
zero card sizes/round gaps fail validation. avatarUrl and metadata are carried
as opaque data; default cards do not perform network requests.
Viewport controller #
final controller = BracketViewportController();
BracketView(
bracket: bracket,
controller: controller,
viewportConfig: const BracketViewportConfig(minScale: .5, maxScale: 2),
);
controller.fit();
controller.zoomIn();
controller.zoomOut();
controller.focusMatch('m1');
controller.reset();
- First mount fits the complete canvas with 24 logical pixels of padding.
- Effective minimum scale is
min(config.minScale, fitScale), so 128/256-player brackets can fit even when the configured minimum is 0.5. reset()returns to the origin at scale 1, clamped to scale bounds.- Zoom changes scale by 1.2 around the viewport center. Focus centers a known
match at readable scale 1, clamped to fit its card and the scale bounds;
unknown IDs are ignored. Use
focusMatch('m1', scale: .8)to request another scale. This deliberately changes the old keep-current-scale focus behavior. - Before layout, the latest controller command is queued. Call commands for newly supplied data after that frame completes.
- Score updates do not reset the transform. Topology changes and resizes
retain the transform within the new scale bounds; call
fit()to reframe. - Attach a controller to only one view. Remove the view before disposing its
controller (a containing State can dispose it in its own
dispose()). Without an external controller, the view manages its own controller.
Virtualization and overview #
BracketView(
bracket: bracket,
renderConfig: const BracketRenderConfig(
virtualizationEnabled: true,
overviewEnabled: true,
overscan: 200,
),
onRenderStats: (stats) {
// Optional diagnostics, notified after a frame when counts/mode change.
// stats.mountedMatchCount, stats.visibleMatchCount, stats.isOverview
},
)
Both policies are enabled by default. Detailed cards and headers are mounted only in the viewport plus 200 logical screen pixels on each side (converted to scene coordinates). Panning uses the cached column/row index; it does not normalize the graph or calculate layout. Connector paths are cached and queried by intersecting segments, so an edge crossing the screen still appears even when both endpoint cards are offscreen.
At less than 40 screen pixels of projected card height, overview replaces
cards with painted nodes. Full cards return above 56 pixels; this hysteresis
prevents flicker during pinch zoom. Overview does not invoke matchBuilder
and has no participant text/images. Running/winner, selection and highlight
states are represented by node colors. Tap a node to focus/zoom into its card;
this does not fire onMatchTap. Keyboard/screen-reader activation focuses
the nearest visible node. Default detailed cards retain their semantics.
Offscreen custom cards are disposed and recreated on return. Keep business state outside the card. IDs provide stable keys while mounted, but not a permanent keep-alive cache. At most one keyboard-focused card stays mounted offscreen until focus leaves; selection alone does not pin a card. Therefore overview normally mounts zero full cards, with a possible focused-card exception.
overscan must be finite and non-negative. Disable overview to keep readable
custom content at every scale; when the entire bracket fits on screen all
cards will then be mounted. Disable both policies for the eager benchmark
baseline. Optional BracketRenderStats is diagnostic only, not a layout API.
Cache and limitations #
Validation/normalization runs when the view rebuilds. A single structural snapshot caches node and edge layout, independent of scores, status, participant names, metadata, selection and theme colors. Changing progression, source references, section/round membership or dimensions recomputes layout and its spatial index. Score/status updates refresh mounted cards; offscreen cards receive the latest values when remounted. Unchanged match instances are reused when builder/callback/style inputs are also unchanged; replacing every match object can rebuild every visible card, but never mounts offscreen cards by itself.
Fixed card height only. Initial layout collision checking still uses a simple O(edges × nodes) scan; virtualization does not remove that one-time cost. The main performance target remains 128–256 participants, with 1024 as a stress case rather than a mobile frame-rate guarantee. Cross-section/skipped-round edges use clear gutters; edge-to-edge crossings may still occur. Arbitrary graph aesthetics are not a V1 guarantee. No networking, tournament generation, scoring rules, scheduling, round robin, Swiss, league or team-tie workflow is included.
Example and checks #
flutter pub get
flutter test
flutter analyze
dart format --output=none --set-exit-if-changed lib test example/lib example/test example/integration_test
cd example
flutter pub get
flutter test
flutter run -d chrome
The example has Single 8/32/128/256/1024, BYE, Incomplete, Double, Custom Card, Themes and Double 128/256/1024 pages. Controls include Reset, Fit, Zoom, Focus, single-match score updates, virtualization/overview toggles and a mounted-card counter. These are local example pages, not routes.
Golden tests load Roboto from the installed Flutter SDK and fix viewport, pixel ratio and platform. Baselines were generated on macOS with Flutter 3.44.6. Review images before accepting platform/SDK-related changes:
flutter test test/golden_test.dart --update-goldens
Run the profile harness on a connected phone (requires local platform setup):
cd example
flutter test integration_test/bracket_profile_test.dart --profile -d DEVICE_ID
The harness compares eager and virtualized rendering, default and custom cards, single and double elimination. Each 128/256-player scenario uses three 30-second samples after warm-up, with pan/zoom/focus and a single score update at 5Hz. The 1024-player stress scenarios use one 10-second sample each. It records mounted cards, opening/focus/fit latency, layout/index changes, Flutter build/raster timings, browser frame intervals and available memory metrics. Browser JS heap excludes GPU/Wasm memory and is not a leak proof.
Run the same harness in an isolated, headless Chrome using Node 22+ (no npm dependencies or chromedriver needed):
cd example
flutter build web --profile --no-web-resources-cdn --target integration_test/bracket_profile_test.dart
node tool/run-web-profile.mjs build/web build/web-profile-full.json
Set CHROME_EXECUTABLE outside macOS. The runner starts a localhost-only server
and a temporary Chrome profile, writes the report, and closes only that Chrome
process. It requires permission to bind a localhost port. This build replaces
example/build/web with the benchmark; rebuild lib/main.dart for the example.
For a smoke run, add --dart-define=BRACKET_SECONDS=1 --dart-define=BRACKET_REPETITIONS=1 --dart-define=BRACKET_WARMUP=0 --dart-define=BRACKET_COUNTS=256 --dart-define=BRACKET_CARDS=default to the build.
Smoke runs validate the harness, not the frame-rate acceptance criteria.
See verification notes for measured results and limitations.
The public graph API remains nested for convenience; normalization and layout implementations are deliberately private. Report bugs through the issue tracker. See CONTRIBUTING.md before opening a pull request.
