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?

In ReoGrid, one class, CellBody, is both the “renderer” and the “editor.”

using unvell.ReoGrid.CellTypes;
using unvell.ReoGrid.Events;      // CellMouseEventArgs

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.

// 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"));
A grid showing a Run button, a ticked checkbox, three radio buttons with the first selected, a dropdown reading In Progress, and a blue hyperlink
Result — the block above, minus its last line: the ImageCell needs a logo.png we do not ship. The checkbox, the first radio and the dropdown are then given a value, since an empty one draws nothing worth looking at. Column A is a label.

Assign one to a cell and it works as-is.


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.SkyBlue;

    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 });
A task column beside a progress column where each cell draws a blue gradient bar with its percentage on top, from 75% down to 0%
Result — the ProgressBarCell above over a column of tasks. The percentages are the cells' own values under the percent format; the bars are what OnPaint draws behind them. Six more values are added so the picture shows a spread rather than two bars.

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.SeaGreen : 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 });

A row where column C reads 50%, column D draws a half-filled blue bar, and column E shows a slider track with its thumb in the middle
Result — the slider at 0.5, the bar reading =E5, and the percent readout reading the same. It is a still, so the thumb is in its resting colour, not the hover green.

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.OrangeRed,
        ["In Progress"] = SolidColor.SteelBlue,
        ["Done"]        = SolidColor.SeaGreen,
        ["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.Silver);

        // Badge background
        var rect = new Rectangle(
            bounds.Left + 2, bounds.Top + 2,
            bounds.Width - 4, bounds.Height - 4);
        dc.Graphics.FillRectangle(rect, 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;
}
A status column where Open is orange-red, In Progress is blue, Done is green and Rejected is grey, each drawn as a filled badge with white centred text
Result — the four statuses the class colours, one per row. The badge is drawn with FillRectangle, so its corners are square.

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


CellBody in WPF

The WPF edition uses the same CellBody inheritance model, and CellDrawingContext.Graphics offers the same drawing API on both platforms. The calling code is identical:

// WinForms
dc.Graphics.FillRectangleLinear(color1, color2, 90f, rect);

// WPF — the same call
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 goes beyond changing the look — it turns the entire cell into 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.

Newsletter

New releases, straight to your inbox

Occasional updates on ReoGrid releases, features, and technical articles. Unsubscribe anytime.

Related articles

Excel Tables in C# — What "Format as Table" Actually Saves, and How to Create One

Excel's "Format as Table" looks like formatting — a coloured header, striped rows — yet not one cell in the saved file carries a fill. What Excel stores is an object: a range, a header row and a style name. This guide opens up the table part of an .xlsx, explains what the 60 built-in styles really contain, and names the three details that decide whether Excel will open the file at all — then builds a table with filter buttons and a totals row in C# with ReoGrid V5.1.

SUBTOTAL vs AGGREGATE — The Totals That Follow the Filter, and When to Use Each

A plain SUM counts rows the filter just hid, which is why a filtered list so often shows a total nobody asked for. SUBTOTAL is the Excel answer, and AGGREGATE is its superset — it can also step over the one

Building an Input Sheet Users Can't Break — Data Validation, Cell Notes, and Sheet Protection in ReoGrid V5

The moment you put a grid on screen, someone deletes a formula and types "12,000 USD" into a price column. ReoGrid V5 ships the three Excel tools that stop that — data validation, cell notes, and sheet protection — and they do genuinely different jobs. We build a quotation template in C# and cover the Excel-compatible gotchas along the way — validation checks only what a person types (paste skips it), the password is Excel's 16-bit verifier rather than encryption, and two permission flags default the other way.