Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

Spreadsheet

SpreadsheetView opens, renders and edits .xlsx workbooks. Both hosts drive the same controller and paint with the same SkiaSharp routine, so MAUI and Blazor are not two implementations kept in step by hand — they are literally the same renderer.

  • NuGet downloads for Shiny.Maui.Controls.Office
  • NuGet downloads for Shiny.Blazor.Controls.Office
Frameworks
.NET MAUI
Blazor

The package is split so that almost none of it is host-specific. Shiny.Controls.Office.Shared owns the OOXML package, the sheet model, a transactional undo stack, the grid layout maths, the interaction logic and the formula engine — with no UI dependency at all, which is why it is covered by several hundred unit tests that never open a window. Shiny.Controls.Office.Skia turns that state into pixels. The two host packages contribute only a Skia surface, raw input forwarding, and a real text box for in-cell editing so the platform’s own keyboard and IME do the typing.

Edits are applied surgically to the open package. The workbook is opened once and held; changes go into the live XML DOM. Nothing is ever reconstructed from a parsed model, so parts the editor does not understand — macros, tracked changes, custom XML, pivot caches, conditional formatting, charts, embedded objects — survive because they are never read in the first place.

Two consequences worth relying on:

  • Opening a workbook and saving it without an edit produces a byte-identical file.
  • Editing one cell rewrites only the sheet, shared strings, workbook and styles parts. Everything else comes back byte-for-byte.

MAUI needs the Skia surface registered, or the canvas never renders:

builder
.UseMauiApp<App>()
.UseShinyControls()
.UseShinyOffice();

UseShinyOffice() calls UseSkiaSharp() for you and, on the macOS AppKit head (net10.0-macos), adds the Skia canvas SkiaSharp itself does not ship — it has no -macos target, so that head otherwise falls back to a handler whose CreatePlatformView() throws and every Office control renders blank. Elsewhere the two calls are equivalent, so call this one instead of UseSkiaSharp().

using var workbook = await Workbook.OpenAsync("/path/to/book.xlsx");
using var workbook = await Workbook.OpenAsync(stream);
using var workbook = Workbook.Create("Sheet1"); // start empty

Workbook is IDisposable and holds the package open — dispose it with the page.

<office:SpreadsheetView x:Name="Sheet"
Workbook="{Binding Workbook}"
SheetName="Budget"
CellChanged="OnCellChanged" />
<div style="height:420px">
<SpreadsheetView Workbook="workbook"
Theme="SpreadsheetTheme.Dark"
CellChanged="OnCellChanged" />
</div>

Every edit goes through the undo stack; never mutate cells directly.

workbook.Execute(new SetCellValueCommand("Budget", CellRef.Parse("B2"), CellValue.FromNumber(42)));
workbook.Execute(new SetCellFormulaCommand("Budget", CellRef.Parse("D2"), "B2*C2"));
workbook.Execute(new ClearRangeCommand("Budget", CellRange.Parse("A1:C3")));
workbook.Undo.Undo();
workbook.Undo.Redo();

A range clear is one undo step, not one per cell, and undoing over a cell that held a formula restores the formula — not the value it happened to be showing.

The engine indexes formulas lazily on the first edit or the first read of a calculated value, then recalculates incrementally in dependency order.

workbook.GetEffectiveValue("Budget", CellRef.Parse("D5")); // computed result
workbook.Evaluate("SUM(A1:A9)", "Budget", CellRef.Parse("Z1")); // ad-hoc, not stored
workbook.Calc.CircularCells; // non-empty on a circular reference

Around 80 functions ship across math, statistics, logic, text, lookup, date and information categories. An unknown function evaluates to #NAME? rather than throwing, and a circular reference is reported and left at zero rather than recursing until the stack dies.

ShowToolbar puts the built-in formatting bar above the formula bar. It is off by default, unlike the formula bar and the tab strip — those are how a workbook is read, and a viewer should not grow a formatting bar it never asked for.

<office:SpreadsheetView Workbook="{Binding Workbook}" ShowToolbar="True" />
<SpreadsheetView Workbook="workbook" ShowToolbar="true" />

Two tabs, split by what a command changes rather than by how often it is reached.

  • Home changes how a cell looks: clipboard; font, size, bold, italic, underline, strikethrough, text colour and cell fill; alignment on both axes, indent and wrap text; number formats and decimal places; AutoSum, clear contents and clear formatting.
  • Data changes the shape of the sheet under it: insert and delete rows and columns; column width — fit-to-contents on the button, four fixed widths behind its chevron — and hide/unhide; and a function library where SUM, AVERAGE, COUNT, MIN and MAX each have a button of their own.

AutoSum is on both, as it is in Excel: it is the one command here reached often enough that a tab switch in front of it would be felt. Every button is one undoable command through the same controller a keyboard shortcut would reach, so a toolbar action and a typed edit share one undo stack.

On Blazor the tab strip is shown by default. ShowTabs="false" does not hide the Data commands — it folds those groups onto the single tab, where the ribbon’s own collapsing deals with the width.

var controller = view.Controller;
controller.ToggleBold(); // Italic, Underline, Strikethrough, WrapText
controller.SetFontFamily("Cambria");
controller.SetFontSize(14);
controller.SetTextColor(new ArgbColor(255, 0xC0, 0x00, 0x00));
controller.SetFillColor(new ArgbColor(255, 0xFF, 0xEB, 0x3B)); // null removes the fill
controller.SetAlignment(CellHorizontalAlignment.Center); // the same value again returns to General
controller.ClearFormatting(); // formatting only; the contents stay
controller.ActiveFormat; // what a toolbar shows the state of

Formatting is applied as a delta, not as a format assigned wholesale. CellFormatChange names only what changes, so bolding a range that mixes a red heading with black body text leaves both colours where they are:

workbook.Execute(new FormatRangeCommand("Budget", CellRange.Parse("A1:D1"), new CellFormatChange
{
Bold = true,
Background = new ArgbColor(255, 0xFF, 0xEB, 0x3B)
}));
controller.SetNumberFormat(NumberFormatPreset.Currency); // culture-aware symbol and placement
controller.SetNumberFormatCode("#,##0.00;[Red](#,##0.00)");
controller.AdjustDecimals(+1); // General becomes 0.0

Presets are General, Number, Currency, Percent, Scientific, ShortDate, Time and Text. The toolbar’s dropdown shows each one applied to a real number rather than naming it, formatted through the same resolver the grid paints with.

controller.ApplyAutoFunction(AutoFunction.Sum); // Average, Count, Min, Max

Where the total goes and what it covers follows Excel, and that is the whole of the feature — the formula itself is one string:

  • One cell selected: the run of numbers immediately above it, or failing that the run to its left, with the result in that cell.
  • A single row or column: just past the end of it — or into its last cell when that cell is empty, which is what selecting the numbers and the blank below them means.
  • A block: one total per column, in the row underneath.

A cell that already holds SUM, AVERAGE, COUNT, MIN or MAX ends the run, so a second total under an existing one does not silently count everything twice.

Select a column from its header and the format is written as a column style — one attribute on one <col> element, exactly as Excel does it — rather than as a million cell styles:

controller.Selection.SelectColumn(2);
controller.SetNumberFormat(NumberFormatPreset.Currency); // C1:C1048576, empty rows included

That is what makes a column formatted as currency still show currency for a value typed into it tomorrow. Row-header selections behave the same way. A cell’s own style still wins over its row’s, which wins over its column’s, and clearing one cell’s formatting does not let the column’s creep back.

Column widths and row heights are recorded in the file now, so a column dragged wider by its header edge — or fitted from the toolbar — survives a save and reopen.

controller.SetColumnWidth(180); // pixels, for the selected columns
controller.AutoFitColumns();
controller.SetColumnsHidden(true);

Home ▸ Find carries a box, a 3/12 readout and a pair of arrows. What is searched is the cell text as the formula bar shows it — the formula when there is one, otherwise the literal — on the active sheet, matching Excel’s own defaults; Find.SearchAllSheets widens it to the whole workbook. See Find in Office Documents.

await workbook.SaveAsync(); // over the path it was opened from
await workbook.SaveAsAsync("/new/path.xlsx");
await workbook.SaveToAsync(stream);
var bytes = workbook.ToArray();

Saving writes atomically — to a sibling temporary file, then a move — so an interrupted save never leaves a half-written document. It also refreshes the cached result of every formula (readers other than Excel show that cached value, so leaving it stale means the file displays wrong numbers) and sets fullCalcOnLoad so Excel re-verifies on open.

var collector = new UnsupportedFeatureCollector();
using var workbook = await Workbook.OpenAsync(path, collector);
foreach (var feature in collector.Features)
Console.WriteLine($"{feature.Part}: {feature.Feature} ({feature.Severity})");

Severities are NotRendered (preserved, not shown), NotEditable (preserved, shown, but edits nearby may not behave) and Lossy (cannot be preserved). Nothing currently reports Lossy, and a document that would should not be saved over its original without asking.

Both hosts expose the same controller, so a toolbar or formula bar drives identical state:

var controller = view.Controller;
controller.Selection.Active; // CellRef
controller.ActiveCellText; // what a formula bar should show
controller.BeginEdit();
controller.Move(MoveDirection.Down, extend: false, toEdge: true); // Ctrl+Down
controller.ClearSelection();
controller.Undo();
controller.ActiveFormat; // the active cell's formatting - see Formatting above
  • Inserting and deleting rows and columns. Deliberately deferred: it is the hardest edit in the format, because references must be rewritten across formulas, merged cells, conditional formatting, defined names, data validation, charts and tables.
  • Adding or removing merged cells — existing merges render but cannot be changed.
  • Editing charts, pivot tables or conditional formatting.
  • Cell borders. Not modelled, so the toolbar cannot apply them — a file’s existing borders are neither drawn nor lost.
  • Wrapped text rendering. The wrap flag is stored and saved and Excel honours it on open, but the grid still paints one line per cell: wrapping needs row auto-height, which the layout has not got.
  • Multi-range (Ctrl-click) selection, copy/paste, and drag-to-fill (the fill handle is drawn but inert).
  • Physical-key navigation on MAUI. MAUI has no portable key-down event, so arrow keys work on Blazor only; on MAUI call Move, BeginEdit and ClearSelection from your own platform key hook.
MAUI Blazor
Spreadsheet on iOS, with the formula bar and a selected cell Spreadsheet on Blazor