When you need pixel-perfect control over your UI, Flutter’s CustomPainter opens the door to beautiful and creative layouts. In this post, I'll show you how I built a circular data grid using CustomPainter, with color-coded values and a dynamic legend.
Use cases for this kind of visualization include:

1.Budget/performance heatmaps
2.Status indicators
3.Visual goal tracking

Let’s jump in! 🚀

🧠 The Data Model

We start by defining some basic models to structure our data:

class DataGridModel {
  final GridLegendLevel value;
  final String? date;
  DataGridModel({required this.value, this.date});
}

class CircularDataGridModel {
  final List data;
  final int length;
  final GridContext context;

  CircularDataGridModel(
   {
    required this.data,
    required this.length,
    required this.context,
  });
}


enum GridContext {
  statusCrimson600IntensityLevels(3),
  statusGold500MutedPerLevels(5);

  final int nosOfColor; //for the different number of colors that can represent the data

  const GridContext(this.nosOfColor);
} // Defines the purpose of the grid

enum GridLegendLevel {
  // Intensity Levels
  high,
  moderate,
  none,

  // Performance Levels
  recommended,
  wellDone,
  stayUnder,
  overTheLimit,
}

This setup gives you a grid based on a list of values, with context-specific coloring.

🎨 Color Mapping Logic

Depending on the context, we assign different color palettes to our cells:

class ColorPalleteMapper {
  static Color getColor(GridContext context, GridLegendLevel value) {
    switch (context) {
      case GridContext.statusCrimson600IntensityLevels:
        return _getStatusCrimson600Muted(value);
      }
  }

  static Color _getStatusCrimson600Muted(GridLegendLevel value) {
    //return color based on the legend level
  }
}

🧩 The Widget Setup

We start by defining a CircularGridPainter that uses Canvas to draw each grid cell.

class CircularGridPainter extends CustomPainter {
  final CircularDataGridModel gridData;

  CircularGridPainter({super.repaint, required this.gridData});

🔢 Calculate Rows, Columns, and Cell Size

int totalCells = gridData.data.length;
  int rows = gridData.length + 1;
  int columns = gridData.length;
  double cellWidth = 30.h;
  double cellHeight = 30.h;

  Paint borderPaint = Paint()
    ..color = Colors.transparent
    ..style = PaintingStyle.stroke
    ..strokeWidth = 2;

  int cellCount = 0;

🔵 Drawing the Circular Cells

for (int row = 0; row < rows; row++) {
    for (int col = 0; col < columns; col++) {
      if (cellCount >= totalCells) break;

      // Optional: draw transparent border rectangle
      Rect cellRect = Rect.fromLTWH(
        col * cellWidth,
        row * cellHeight,
        cellWidth,
        cellHeight,
      );
      canvas.drawRect(cellRect, borderPaint);

      // Center point for the circle
      Offset center = Offset(
        col * cellWidth + cellWidth / 2,
        row * cellHeight + cellHeight / 2,
      );

      // Circle color based on the value
      Paint circlePaint = Paint()
        ..color = ColorPalleteMapper.getColor(
          gridData.context,
          gridData.data[cellCount].value,
        )
        ..style = PaintingStyle.fill;

      double radius = (cellWidth < cellHeight ? cellWidth : cellHeight) / 3;

      canvas.drawCircle(center, radius, circlePaint);
      cellCount++;
    }
  }

This loop handles the drawing logic for every circular cell in the grid.

🧾 Add a Legend for Clarity

To help users understand what each color means, we add a dynamic legend widget:

class LegendItem {
  final String name;
  final Color color;
  LegendItem({required this.name, required this.color});
}

class LegendWidget extends StatelessWidget {
  final GridContext gridContext;
  const LegendWidget({super.key, required this.gridContext});

  @override
  Widget build(BuildContext context) {
    final List legends = LegendMapper.getLegendData(gridContext);
    return Wrap(
      spacing: 16.0,
      runSpacing: 8.0,
      children: legends.map((legend) {
        return Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            CircleAvatar(radius: 5, backgroundColor: legend.color),
            SizedBox(width: 6),
            Text(
              legend.name,
              style: TextStyle(color: legend.color),
            ),
          ],
        );
      }).toList(),
    );
  }
}

✅ Wrapping Up

And that's it! This is a powerful technique for anyone looking to:

  • Build custom UI components
  • Create pixel-precise layouts
  • Visualize data in creative ways

If you'd like to see a full working example, let me know in the comments! I’d be happy to publish a follow-up with demo integration or share the GitHub repo.
Until next time, happy painting! 🖌️

Follow me on dev.to for more Flutter tips and UI tricks.