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.