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<DataGridModel> 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<LegendItem> 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.
 

 
    
Top comments (0)