Skip to main content

flutter_bracket 0.1.0 copy "flutter_bracket: ^0.1.0" to clipboard
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.

Single elimination

  • 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.

  • ParticipantSource requires a participant; ByeSource must not have one.
  • BYE is a side state. The package does not infer advancement; when resolved, keep WinnerOfSource on 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. participants is 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.

Double elimination

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.

1
likes
150
points
283
downloads
screenshot

Documentation

API reference

Publisher

verified publishertuanha.dev

Weekly Downloads

Generic graph-driven elimination brackets with pan, zoom, custom cards and themes.

Repository (GitHub)
View/report issues
Contributing

Topics

#tournament #bracket #sports #visualization #flutter

License

MIT (license)

Dependencies

flutter

More

Packages that depend on flutter_bracket