Skip to content

Instantly share code, notes, and snippets.

@smoak
Created December 18, 2025 23:53
Show Gist options
  • Select an option

  • Save smoak/2f39b5b6b78aa90f481fba329a9b54e9 to your computer and use it in GitHub Desktop.

Select an option

Save smoak/2f39b5b6b78aa90f481fba329a9b54e9 to your computer and use it in GitHub Desktop.
Roster Table Widget for displaying a roster of a team
const double _cellHeight = 44;
const double _numWidth = 40;
const double _nameWidth = 160;
const double _statWidth = 60;
class RosterPlayer {
final int number;
final String name;
final int goals;
final int assists;
final int points;
final int sog;
final String toi;
final int plusMinus;
final int pim;
final String position;
final String avatarUrl;
RosterPlayer({
required this.number,
required this.name,
required this.goals,
required this.assists,
required this.points,
required this.sog,
required this.toi,
required this.plusMinus,
required this.pim,
required this.position,
required this.avatarUrl,
});
}
class RosterHeaderRow extends StatelessWidget {
const RosterHeaderRow({super.key, required this.scrollController});
final ScrollController scrollController;
@override
Widget build(BuildContext context) {
return SizedBox(
height: _cellHeight,
child: Row(
children: [
const FixedColumnHeaderCell("#", width: _numWidth),
const FixedColumnHeaderCell("Name", width: _nameWidth),
Expanded(
child: ListView(
controller: scrollController,
physics: const ClampingScrollPhysics(),
scrollDirection: Axis.horizontal,
children: [
const ColumnHeaderCell("G"),
const ColumnHeaderCell("A"),
const ColumnHeaderCell("P"),
const ColumnHeaderCell("SOG"),
const ColumnHeaderCell("TOI"),
const ColumnHeaderCell("+/-"),
const ColumnHeaderCell("PIM"),
const ColumnHeaderCell("POS"),
],
),
),
],
),
);
}
}
class FixedColumnHeaderCell extends StatelessWidget {
final String label;
final double width;
const FixedColumnHeaderCell(this.label, {required this.width, super.key});
@override
Widget build(BuildContext context) {
return Container(
height: _cellHeight,
width: width,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 8, right: 8),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withAlpha(150),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600),
),
);
}
}
class ColumnHeaderCell extends StatelessWidget {
final String label;
const ColumnHeaderCell(this.label, {super.key});
@override
Widget build(BuildContext context) {
return Container(
height: _cellHeight,
width: _statWidth,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withAlpha(150),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600),
),
);
}
}
class RosterTable extends StatefulWidget {
final Iterable<RosterPlayer> skaters;
const RosterTable({super.key, required this.skaters});
@override
State<StatefulWidget> createState() => _RosterTableState();
}
class RosterFixedColumns extends StatelessWidget {
final ScrollController scrollController;
final Iterable<RosterPlayer> skaters;
const RosterFixedColumns({
super.key,
required this.scrollController,
required this.skaters,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: _numWidth + _nameWidth, // 50 + 90 + 60
child: ListView.builder(
// separatorBuilder: (_, _) => const Divider(),
controller: scrollController,
itemCount: skaters.length,
itemBuilder: (_, index) {
final skater = skaters.elementAt(index);
return InkWell(
onTap: () {
},
child: Row(
children: [
Container(
height: _cellHeight,
width: _nameWidth + _numWidth,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
decoration: BoxDecoration(
color: index.isEven
? Colors.white
: Theme.of(context).primaryColor.withAlpha(75),
),
child: Row(
children: [
Expanded(flex: 1, child: Text(skater.number.toString())),
Expanded(
flex: 3,
child: Text(
skater.name,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
],
),
),
],
),
);
},
),
);
}
}
class StatCell extends StatelessWidget {
final Color color;
final String statText;
const StatCell({super.key, required this.color, required this.statText});
@override
Widget build(BuildContext context) {
return Container(
height: _cellHeight,
width: _statWidth,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(color: color),
child: Text(statText),
);
}
}
class RosterStatColumns extends StatelessWidget {
final Iterable<RosterPlayer> skaters;
final ScrollController scrollController;
final List<ScrollController> statValuesHorizontalScrollControllers;
const RosterStatColumns({
super.key,
required this.skaters,
required this.scrollController,
required this.statValuesHorizontalScrollControllers,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: ListView.builder(
controller: scrollController,
itemCount: skaters.length,
itemBuilder: (_, index) {
// final LeagueTableTeam leagueTableTeam = leagueTableTeams[index];
final skater = skaters.elementAt(index);
final color = index.isEven
? Colors.white
: Theme.of(context).primaryColor.withAlpha(75);
return SingleChildScrollView(
physics: const ClampingScrollPhysics(),
controller: statValuesHorizontalScrollControllers[index],
scrollDirection: Axis.horizontal,
child: Row(
children: [
StatCell(color: color, statText: skater.goals.toString()),
StatCell(color: color, statText: skater.assists.toString()),
StatCell(color: color, statText: skater.points.toString()),
StatCell(color: color, statText: skater.sog.toString()),
StatCell(color: color, statText: skater.toi),
StatCell(color: color, statText: skater.plusMinus.toString()),
StatCell(color: color, statText: skater.pim.toString()),
StatCell(color: color, statText: skater.position),
],
),
);
},
),
);
}
}
class _RosterTableState extends State<RosterTable> {
late final LinkedScrollControllerGroup horizontalScrollControllersGroup;
late final LinkedScrollControllerGroup verticalScrollControllersGroup;
late final ScrollController statHeadersHorizontalScrollController;
late final List<ScrollController> statValuesHorizontalScrollControllers;
late final ScrollController teamInfoVerticalScrollController;
late final ScrollController teamStatsVerticalScrollController;
@override
void initState() {
super.initState();
horizontalScrollControllersGroup = LinkedScrollControllerGroup();
statHeadersHorizontalScrollController = horizontalScrollControllersGroup
.addAndGet();
statValuesHorizontalScrollControllers = [];
statValuesHorizontalScrollControllers.addAll(
List.generate(
widget.skaters.length,
(_) => horizontalScrollControllersGroup.addAndGet(),
),
);
verticalScrollControllersGroup = LinkedScrollControllerGroup();
teamInfoVerticalScrollController = verticalScrollControllersGroup
.addAndGet();
teamStatsVerticalScrollController = verticalScrollControllersGroup
.addAndGet();
}
@override
void dispose() {
statHeadersHorizontalScrollController.dispose();
for (final ScrollController controller
in statValuesHorizontalScrollControllers) {
controller.dispose();
}
teamInfoVerticalScrollController.dispose();
teamStatsVerticalScrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
RosterHeaderRow(
scrollController: statHeadersHorizontalScrollController,
),
Expanded(
child: Row(
children: [
// The fixed columns on the left
RosterFixedColumns(
skaters: widget.skaters,
scrollController: teamInfoVerticalScrollController,
),
// The horizontally scrollable columns on the right
RosterStatColumns(
skaters: widget.skaters,
scrollController: teamStatsVerticalScrollController,
statValuesHorizontalScrollControllers:
statValuesHorizontalScrollControllers,
),
],
),
),
],
);
}
}
@smoak
Copy link
Author

smoak commented Dec 18, 2025

screen-20251218-155354.webm

@smoak
Copy link
Author

smoak commented Dec 19, 2025

For comparison the NHL app's version:

screen-20251218-160019.webm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment