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

MATCH vs XMATCH — The Lookup Functions That Return a Position, and When to Use Each

MATCH and XMATCH both return "where a value sits (its position)" rather than the value itself. They differ in their default match mode, reverse search, and wildcard handling. This guide sorts out the differences with examples, shows how to combine them with INDEX to pull values more flexibly than VLOOKUP, and demonstrates how ReoGrid (supported in V4.5) runs the same formulas inside a WinForms / WPF app — no Office required.

VLOOKUP vs HLOOKUP vs XLOOKUP — Knowing the Difference and Using Them in a C# Spreadsheet

VLOOKUP, HLOOKUP, and XLOOKUP look alike, but they differ in which direction they search, how you point at the value to return, and what happens when nothing is found. This guide sorts out the differences with tables and examples, shows why XLOOKUP fixes VLOOKUP's weak spots, and demonstrates how ReoGrid (supported in V4.5) runs the very same formulas inside a WinForms / WPF app — no Office required.

When Search and Deduplication Quietly Fail on Japanese Data — Normalizing Full-Width / Half-Width Text in a C# App

An address in half-width kana, a phone number in full-width digits, the same company filed twice as "(株)" and "(株)" — Japanese input data mixes full-width and half-width characters, and as-is, search, deduplication, and totals all silently break. With ReoGrid you can bulk-convert using the Excel-compatible JIS / ASC functions, and auto-normalize on entry via AfterCellEdit. Build business data in C# that doesn't break on width inconsistency.