Adding Custom Cells to a WinForms or WPF Spreadsheet — Progress Bars, Sliders, and Dropdowns with CellBody

· unvell team
Adding Custom Cells to a WinForms or WPF Spreadsheet — Progress Bars, Sliders, and Dropdowns with CellBody

If all you need is flat data in a grid, DataGridView is fine. But the moment someone says “I want a progress bar in this column,” “this row should have a slider,” or “put a dropdown right in this cell,” standard controls start requiring a lot of scaffolding.

ReoGrid answers those requirements with a single concept: CellBody. Subclass it, override what you need, assign it to a cell — and you have full control over both rendering and interaction.


What is CellBody?

ReoGrid doesn’t separate “renderers” from “editors.” One class, CellBody, covers both.

using unvell.ReoGrid.CellTypes;

public class MyCellBody : CellBody
{
    // Painting — called every time the cell is redrawn
    public override void OnPaint(CellDrawingContext dc) { ... }

    // Mouse events
    public override bool OnMouseDown(CellMouseEventArgs e) { ... }
    public override bool OnMouseMove(CellMouseEventArgs e) { ... }
    public override bool OnMouseEnter(CellMouseEventArgs e) { ... }
    public override bool OnMouseLeave(CellMouseEventArgs e) { ... }

    // Allow or block direct text editing
    public override bool OnStartEdit() { return false; }

    // Called when the body is assigned to a cell
    public override void OnSetup(Cell cell) { base.OnSetup(cell); }
}

Assigning it to a cell is a single indexer assignment:

var sheet = reoGridControl1.CurrentWorksheet;
sheet["B3"] = new MyCellBody();

Built-in CellBody types

Common use cases ship ready to use. All live in the unvell.ReoGrid.CellTypes namespace.

// Button
var btn = new ButtonCell("Run");
btn.Click += (s, e) => MessageBox.Show("Clicked!");
sheet["B2"] = btn;

// Checkbox
var chk = new CheckBoxCell();
chk.CheckChanged += (s, e) => Console.WriteLine(chk.IsChecked);
sheet["B4"] = chk;

// Radio buttons (linked via a group)
var group = new RadioButtonGroup();
sheet["B6"] = new RadioButtonCell { RadioGroup = group };
sheet["B7"] = new RadioButtonCell { RadioGroup = group };
sheet["B8"] = new RadioButtonCell { RadioGroup = group };

// Dropdown list
var dd = new DropdownListCell("Open", "In Progress", "Done", "Rejected");
dd.SelectedItemChanged += (s, e) => Console.WriteLine(dd.SelectedItem);
sheet["B10"] = dd;

// Hyperlink
sheet["B12"] = new HyperlinkCell("https://reogrid.net");

// Image
sheet["B14"] = new ImageCell(Image.FromFile("logo.png"));

No extra wiring required — assign and go.


Example 1: Progress bar cell

Reads the cell value (0.0–1.0) and paints a gradient bar proportional to that value. This is the simplest possible CellBody implementation.

using unvell.ReoGrid.CellTypes;
using unvell.ReoGrid.Graphics;
using unvell.ReoGrid.Rendering;

public class ProgressBarCell : CellBody
{
    public SolidColor BarColor    { get; set; } = SolidColor.SteelBlue;
    public SolidColor BarColorEnd { get; set; } = SolidColor.CornflowerBlue;

    public override void OnPaint(CellDrawingContext dc)
    {
        double value = Cell.Worksheet.GetCellData<double>(Cell.Position);
        value = Math.Clamp(value, 0, 1);

        var bounds   = GetBodyBounds();
        int barWidth = (int)(value * bounds.Width);

        if (barWidth > 0)
        {
            var rect = new Rectangle(bounds.Left, bounds.Top + 1, barWidth, bounds.Height - 2);
            dc.Graphics.FillRectangleLinear(BarColor, BarColorEnd, 90f, rect);
        }

        // Let ReoGrid draw the formatted cell text on top
        dc.DrawCellText();
    }

    // Block direct text editing — value is set via API or formula
    public override bool OnStartEdit() => false;
}

Usage:

// Make column B a progress-bar column
for (int r = 2; r <= 11; r++)
    sheet[r, 1] = new ProgressBarCell();

// Set values (0.0–1.0)
sheet[2, 1] = 0.75;
sheet[3, 1] = 0.42;

// Apply percent format — cells now display "75%", "42%", etc.
sheet.SetRangeDataFormat(2, 1, 10, 1,
    DataFormat.CellDataFormatFlag.Percent,
    new DataFormat.NumberDataFormatter.NumberFormatArgs { DecimalPlaces = 0 });

You can also drive the bar from a formula so it updates automatically:

// Column C = actual, Column D = target → Column B reflects the ratio live
sheet[2, 1] = "=C2/D2";

Example 2: Slider cell

Lets users drag a thumb inside the cell to change its value. OnMouseDown and OnMouseMove translate cursor position back into a 0–1 float and write it to the worksheet.

public class SliderCell : CellBody
{
    private bool isHover = false;

    public override void OnSetup(Cell cell)
    {
        base.OnSetup(cell);
    }

    public override void OnPaint(CellDrawingContext dc)
    {
        float value = 0;
        float.TryParse(dc.Cell.DisplayText, out value);
        value = Math.Clamp(value, 0f, 1f);

        var bounds  = Cell.GetBounds();
        var g       = dc.Graphics;
        int halfH   = (int)(bounds.Height / 2f);
        int sliderH = (int)Math.Min(bounds.Height - 4, 20);

        // Track background
        g.FillRectangle(4, halfH - 3, bounds.Width - 8, 6, SolidColor.Gainsboro);

        // Thumb
        int thumbX   = 2 + (int)(value * (bounds.Width - 12));
        var thumbRect = new Rectangle(thumbX, halfH - sliderH / 2, 8, sliderH);
        g.FillRectangle(thumbRect, isHover ? SolidColor.MediumSeaGreen : SolidColor.LightGreen);
    }

    public override bool OnMouseDown(CellMouseEventArgs e)
    {
        SetValueFromX(e.RelativePosition.X);
        return true; // mark as handled — suppress default selection behaviour
    }

    public override bool OnMouseMove(CellMouseEventArgs e)
    {
        if (e.Buttons == unvell.ReoGrid.Interaction.MouseButtons.Left)
            SetValueFromX(e.RelativePosition.X);
        return false;
    }

    public override bool OnMouseEnter(CellMouseEventArgs e) { isHover = true;  return true; }
    public override bool OnMouseLeave(CellMouseEventArgs e) { isHover = false; return true; }

    public override bool OnStartEdit() => false;

    private void SetValueFromX(float x)
    {
        float value = x / (Cell.GetBounds().Width - 2f);
        value = Math.Clamp(value, 0f, 1f);
        Cell.Worksheet.SetCellData(Cell.Position, Math.Round(value, 2));
    }
}

Linking a slider to a progress bar in another cell is just a formula:

// E5 — slider
sheet["E5"] = new SliderCell();
sheet["E5"] = 0.5;

// D5 — progress bar driven by the slider value
sheet["D5"] = new ProgressBarCell();
sheet["D5"] = "=E5";

// C5 — numeric readout
sheet["C5"] = "=E5";
sheet.SetRangeDataFormat("C5", DataFormat.CellDataFormatFlag.Percent,
    new DataFormat.NumberDataFormatter.NumberFormatArgs { DecimalPlaces = 0 });

Example 3: Status badge cell

A common requirement in business apps is a “Status” column that reads like a label but looks like a coloured badge. The body reads Cell.DisplayText and switches background colour accordingly.

public class StatusBadgeCell : CellBody
{
    private static readonly Dictionary<string, SolidColor> StatusColors = new()
    {
        ["Open"]        = SolidColor.Tomato,
        ["In Progress"] = SolidColor.SteelBlue,
        ["Done"]        = SolidColor.MediumSeaGreen,
        ["Rejected"]    = SolidColor.Gray,
    };

    public override void OnPaint(CellDrawingContext dc)
    {
        string text   = Cell.DisplayText;
        var    bounds = GetBodyBounds();

        var bgColor = StatusColors.TryGetValue(text, out var c) ? c
            : new SolidColor(200, SolidColor.LightGray);

        // Rounded rectangle background
        var rect = new Rectangle(
            bounds.Left + 2, bounds.Top + 2,
            bounds.Width - 4, bounds.Height - 4);
        dc.Graphics.FillRoundedRectangle(rect, 4, bgColor);

        // White label text, centred
        dc.Graphics.DrawText(
            text,
            dc.Cell.CalcedStyle?.FontName ?? "Segoe UI",
            dc.Cell.CalcedStyle?.FontSize ?? 9f,
            SolidColor.White,
            bounds,
            ReoGridHorAlign.Center,
            ReoGridVerAlign.Middle);
    }

    // Allow typing to change the status directly
    public override bool OnStartEdit() => true;
}

To pair it with a dropdown, inherit from DropdownListCell instead and override OnPaint there.


CellBody in WPF

The WPF package (unvell.ReoGridWPF.dll) uses the same CellBody inheritance model. The only difference is that CellDrawingContext.Graphics dispatches to a WPF DrawingContext wrapper instead of System.Drawing.Graphics. The calling code is identical:

// WinForms — delegates to System.Drawing.Graphics
dc.Graphics.FillRectangleLinear(color1, color2, 90f, rect);

// WPF — same call, different backend
dc.Graphics.FillRectangleLinear(color1, color2, 90f, rect);

If you share a class library between WinForms and WPF projects, one CellBody subclass compiles and works in both.


Applying a CellBody to a range

Override Clone() so each cell gets its own independent instance:

public class ProgressBarCell : CellBody
{
    // ... painting code

    public override ICellBody Clone()
    {
        return new ProgressBarCell
        {
            BarColor    = this.BarColor,
            BarColorEnd = this.BarColorEnd,
        };
    }
}

// Apply to a column in one loop
for (int r = 1; r <= 20; r++)
    sheet[r, 2] = new ProgressBarCell { BarColor = SolidColor.Coral };

Without Clone(), bulk APIs like SetRangeData may share a single instance across cells, which can cause surprising state bugs.


Quick reference

GoalApproach
Use a ready-made dropdown / checkboxDropdownListCell / CheckBoxCell as-is
Visualise a value graphicallyOverride CellBody.OnPaint
Let mouse interaction change a valueOverride OnMouseDown / OnMouseMove
Block direct text entryReturn false from OnStartEdit
Share one class across WinForms and WPFPut the subclass in a shared class library
Apply to a range safelyOverride Clone()

CellBody is not just “change the look” — it replaces the entire cell with a custom UI component. The same pattern scales from simple progress bars to calendar pickers, star-rating inputs, or anything else that belongs in a cell.


Further reading

Try ReoGrid in your own project

The Excel-compatible spreadsheet component for .NET WinForms and WPF. 30-day free trial — no credit card required.

Related articles