SwiftDotNet has one Core and three interpreter families. The C# side owns the view tree (React-Native style); each backend reconstructs native UI from it. A diff engine turns every re-render into a minimal patch so only changed nodes reach the renderer.
C# DSL (View/State)
│ ToNode() → TreeDiffer ┌──────────────────────────────┐
▼ │ SwiftDotNetBridge.xcframework │
Patch ──JSON──► swiftdotnet_render ──► apply to @Observable VNode tree ──► NodeView → real SwiftUI
▲ │ │ tap / edit / toggle
│ State.Value = … ◄── SwiftApp.OnEvent(id,value) ◄── [UnmanagedCallersOnly] ◄──┘ @convention(c)
└── re-render ───────────────────┘ (node id + value payload)
The diagram shows the iOS/SwiftUI path. The bridge is a native shim on iOS/tvOS/macOS (Swift) and Android (Kotlin), and an in-process interpreter on the pure-C# backends (GTK / WinUI / WPF / Web / TUI / Skia / WebGPU) — but the patch protocol and event round-trip are identical everywhere.
Everything platform-neutral lives in src/SwiftDotNet/Core and compiles for
every TFM:
| Piece | File | Role |
|---|---|---|
| DSL / view base | View.cs, Views/* |
The declarative surface: View, VStack, Text, … |
| Reactive state | State.cs |
State<T>; assigning .Value invalidates + re-renders |
| Node model | Node.cs, NodeBuilder.cs |
The serializable tree a View lowers to via ToNode() |
| Serialization | NodeJson.cs |
Hand-rolled JSON — zero reflection, trim/AOT-safe (no IL2026) |
| Diff engine | TreeDiffer.cs |
Produces replace / updateProps / setChildren patches |
| Bridge contract | IBridge.cs |
The one interface each backend implements |
| Runtime | SwiftApp.cs |
Drives render, applies patches through IBridge, routes events |
| Styling | EnvironmentValues.cs, Styles.cs, Theme.cs, Modifier.cs |
See Global Styles |
| Layout math | GridLayout.cs, GridEngine.cs |
GridTrack/LayoutFlags and the shared grid-placement + proportional-bounds math — see below |
The Core is dependency-free. Each backend pulls in only its own toolkit.
Core normally describes; GridEngine decides. Almost everything above is declarative — the Core lowers
a view to a node and each backend interprets it however its toolkit prefers. Grid
and AbsoluteLayout are the exception: which cell a child lands in,
and what a proportional bound resolves to, are answered once in Core
(GridEngine, AbsoluteLayoutBounds)
and consumed by every C# backend, because seven independent implementations of "where does a pinned child go"
would silently disagree. Track sizing stays per-backend, since GTK/WinUI/TUI/Web all hand it to a native
grid that already does it; only Skia computes it from scratch, and the Swift and Kotlin shims — which can't
call into Core — port both halves line for line.
Node ids are structural paths ("0.2.1" = root → child 2 → child 1), stable across renders, so the
differ targets nodes by id:
- a prop change emits
updatePropsfor just that node; - a changed child list emits
setChildrenon the parent; - identical renders emit nothing.
Two-way-bound controls (TextField, Toggle, …) are backend "controlled components" whose local state syncs
both directions — on SwiftUI/Compose via an observable @State/mutableStateOf synced through onChange.
Keyed containers. For a keyed List, DiffNode emits setChildren when the child key sequence
changes (otherwise it recurses positionally). Ids stay positional; identity rides as a key prop. This is
what makes reorders cheap instead of looking like N in-place updateProps. See
Collection View.
Two families, chosen by whether the target toolkit is C#-bindable:
SwiftUI and Jetpack Compose are compiler-plugin frameworks — you cannot author a SwiftUI View or a
Compose @Composable from C#. So these backends ship a thin native shim that reconstructs the tree:
- Swift (
native/SwiftDotNetBridge) →SwiftDotNetBridge.xcframework. C# talks to it over a C ABI:swiftdotnet_render(json)— C# pushes a patch; Swift applies it to an observedVNodetree so unchanged subtrees never rebuild.swiftdotnet_set_event_callback(fn)— Swift calls it on events with a node id + optional value.swiftdotnet_make_host_controller()— returns aUIHostingController(orNSHostingController) that C# hosts as the root.
- Kotlin (
native/SwiftDotNetComposeBridge) →.aar, same protocol over JNI, withmutableStateOfVNodes.
P/Invoke resolves the Swift bridge via DllImport("__Internal") — it's a load-time dependency, so its
@_cdecl symbols are in the global namespace (a leaf-name dlopen would ignore @rpath). See
Apple backend and Android backend.
GTK4, WinUI 3, Blazor/DOM, XenoAtom.Terminal.UI, and the self-drawing canvases are all fully C#-bindable (or
self-drawn), so those backends are pure C# with no native code — a retained-mode interpreter that maps
the node tree straight to native controls (or DOM elements, terminal cells, or canvas draws) and applies the
same diff patches. Each implements IBridge and resolves nodes with a positional Find(id). See
GTK, Windows, Web,
Terminal/TUI, Skia, WebGPU and
Unity.
One Core, three families. The DSL,
State<T>,Node,TreeDiffer, patch protocol, andSwiftAppare shared verbatim. Only the leaf renderer differs: a native shim for the compiler-locked toolkits, a pure-C# widget interpreter for the bindable ones, and a self-drawing engine for Skia/WebGPU/Unity.
A self-drawing backend is a whole UI toolkit — measure, arrange, hit-test, gesture recognition, scrolling,
animation, and painting. Only the last of those is actually about the rasterizer. So the self-drawing
engine lives once, in SwiftDotNet.Graphics, and a rasterizer supplies three
small interfaces:
| Interface | Supplies | Why it exists |
|---|---|---|
ICanvas |
The paint primitives | The whole drawing vocabulary, deliberately closed |
IFontProvider |
Fonts + measurement | The layout pass needs text metrics long before anything is drawn |
IImageDecoder |
Bytes → a drawable image | The decode target is rasterizer-specific (bitmap vs. GPU texture) |
SwiftDotNet.Graphics
VisualBridge ─► VisualNode: Measure / Arrange / HitTest / Draw
│
ICanvas · IFontProvider · IImageDecoder
┌────────────────────┼────────────────────┐
SkiaCanvas WebGpuCanvas GodotCanvas
│ (Godot's own 2D
(MonoGame and Unity draw commands)
reuse SkiaCanvas)
The split is roughly 3,600 lines of engine to 530 lines of Skia adapter — a good measure of how little of a self-drawing toolkit is really about the rasterizer.
Two design choices in ICanvas are worth knowing:
- The vocabulary is closed and small: rounded rects, ovals, circles, lines, images and text, under a save/restore transform stack with rectangular clipping. That is the complete set the DSL's node types draw. Notably absent is an arbitrary path primitive — adding one would make every future backend owe a full vector rasterizer, so it belongs behind a capability check rather than in the interface.
- Shadows and gradients are descriptions, not objects. The engine used to hang an
SKImageFilter.CreateDropShadow(...)off its paint, which forces every backend to own an image-filter graph. Carrying a shadow as four numbers instead lets the Skia adapter rebuild exactly that filter while the WebGPU backend renders it as an SDF falloff in the same draw call.
Two things the original Skia-shaped seam did not anticipate, both added because a host needed them and neither specific to one backend:
VisualBridge.ClearColor— the paint pass clears to the theme's window background, which is right for a UI that owns the window and wrong for a HUD drawn over a game scene. A host that composites sets it to a transparent colour. It lives on the bridge becauseDrawowns the clear.FrameLoopSyncContext— game loops have no synchronization context, so an off-threadState<T>mutation would rebuild the tree while the paint pass reads it. Every loop-driven host installs one beforeSwiftApp.Runand drains it once per frame.
Godot is the seam's real test, and it passed: it is the first ICanvas
implementation whose target is a retained renderer, where clipping and group opacity are properties of a
scene object rather than of a draw call. Everything above the seam was reused unchanged. The one thing the
port needed was care about draw ordering — see that page for the specific trap.
See Skia, WebGPU, MonoGame, Godot and Unity.
A self-drawing backend owns every pixel, which means there are controls it can never draw: a WebView, a
map, an embedded .NET MAUI view. Those are not a rasterizer problem — no ICanvas primitive will ever
produce a live web page — so they need a second, smaller seam beside the renderer one.
The engine reports placements; a host decides what a placement means.
| Piece | Role |
|---|---|
PlatformViews |
Static registry of node types that are a control, not paint. Registering one stops the engine painting it. |
PlatformViewPlacement |
Where one such node landed this frame: id, type, frame, clip, visibility, props |
IPlatformViewHost |
Implemented by a host that can float a real OS control over the canvas |
The paint pass records a placement instead of drawing, and VisualBridge.Draw hands the host the
complete set once per frame — never a delta, because a set-reconcile is the only shape that cannot leak
a control when a subtree vanishes through a setChildren patch. Decorations (.Background, .Border) are
still painted underneath, so the SwiftDotNet chrome around a transparent native control survives.
Two consequences are structural rather than incidental:
- Z-order inverts. A native view always floats above canvas pixels, so anything the engine paints over
it —
Sheet,Alert,Menu, a pushed nav destination — would render behind it. The engine resolves this by layer: only placements recorded in the topmost painted layer stay visible. - Transforms don't apply.
.Offset/.ScaleEffect/.Rotationare canvas-matrix operations at paint time and are never folded into a node's frame, so a placement is the untransformed layout rect.
A host that cannot place native views simply never sets VisualBridge.PlatformViewHost, and every such node
keeps painting the placeholder it always did — which is why registering WebView changes its behaviour on
the MAUI host and nowhere else. See MAUI Interop for the first two consumers.
| Path | TFM | Role |
|---|---|---|
src/SwiftDotNet |
multi-target | One library. Core/ compiles for every TFM; Platforms/{iOS,macOS,tvOS,Android,Windows}/ are opted in per TFM. |
src/SwiftDotNet.Gtk |
net10.0 |
Separate pure-C# GTK4 backend (Linux shares net10.0 with Core, so folding it in would force GTK on every consumer). |
src/SwiftDotNet.Web |
net10.0 (Razor) |
Separate Blazor WebAssembly backend. |
src/SwiftDotNet.Graphics |
net10.0, net8.0, netstandard2.1 |
The self-drawing engine — layout, hit-testing, gestures, paint pass — minus any rasterizer. Dependency-free, like Core. |
src/SwiftDotNet.Skia |
net10.0, net8.0, netstandard2.1 |
The SkiaSharp binding of that engine's seam (canvas, fonts, image decode) plus hosts. |
src/SwiftDotNet.WebGpu |
net10.0 |
A from-scratch GPU rasterizer for the same seam: SDF shapes, a glyph atlas, wgpu-native. No Skia. |
src/SwiftDotNet.MonoGame |
net10.0, net8.0 |
MonoGame host: a DrawableGameComponent that draws the Skia engine into a Texture2D. |
src/SwiftDotNet.Godot |
net8.0 (Godot.NET.Sdk) |
Godot host: a Control node and an ICanvas on Godot's own 2D renderer. No Skia, no native library. |
src/SwiftDotNet.Godot.Skia |
net8.0 (Godot.NET.Sdk) |
The Skia-into-a-texture variant of that control; separate so the native route stays dependency-free. |
unity/com.swiftdotnet.unity |
Unity package | The Unity host: draws the Skia engine into a Texture2D and pumps input. |
src/SwiftDotNet.Tui |
net10.0 |
Separate pure-C# terminal backend over XenoAtom.Terminal.UI; includes its own PNG decoder and image→character-art renderer. |
src/SwiftDotNet.Tui.Graphics |
net10.0 |
Optional add-on: Sixel/Kitty/iTerm2 pixel images (pulls SkiaSharp, hence separate from the backend). |
src/SwiftDotNet.Skia.Maui |
net10.0-maccatalyst (+more) |
MAUI adapter hosting the Skia engine; composes with Shiny. |
native/SwiftDotNetBridge |
Swift | SwiftUI interpreter → xcframework (5 slices). |
native/SwiftDotNetComposeBridge |
Kotlin | Compose interpreter → .aar. |
sample/SharedUI |
net10.0 |
The demo ContentView + composite Rating, shared by all apps. |
sample/SampleApp |
multi-target | One sample app, multi-targeted like the library. |
Why some backends are separate projects rather than TFMs of the combined library: GTK, Web, Skia, WebGPU
and the terminal backend all share the plain net10.0 TFM with Core, so there's no TFM to distinguish them —
folding them in would force their dependency (Gir.Core, Blazor, SkiaSharp, wgpu-native,
XenoAtom.Terminal.UI) onto every neutral consumer. WPF and the two Windows-desktop Skia hosts
(SwiftDotNet.Wpf, SwiftDotNet.Skia.Wpf, SwiftDotNet.Skia.WindowsForms) are separate for the first
half of that reason rather than the second: they do have a distinct TFM (net10.0-windows), but it is
Windows-only, so a TFM of the multi-target library would make every consumer's restore Windows-shaped. They
also deliberately stay at the default platform version rather than 10.0.19041 — that higher TPV is
also compatible with the WinUI 3 TFM, so a consumer would end up compiling against two Windows backends at
once. SwiftDotNet.Graphics is the exception that proves the
rule: it is dependency-free, which is exactly why it can sit between Core and every self-drawing backend.
Core, SwiftDotNet.Graphics and SwiftDotNet.Skia also target netstandard2.1 (Unity's scripting
runtime) and net8.0 (Godot's) — see
Unity → why Core multi-targets netstandard2.1.
The net8.0 target is not redundant with netstandard2.1: init-only setters carry a modreq on
IsExternalInit, which is a polyfilled internal type on netstandard2.1 and a BCL type from net5.0 on, so
an assembly compiled against the netstandard2.1 build throws MissingMethodException the first time it
evaluates a with expression against the net10.0 one. Mixed-TFM consumers need a real net8.0 build.
The per-OS bootstrap lives in the library as reusable abstract hosts, so an app's platform entry point is a one-liner:
Base host (in SwiftDotNet) |
Platform | Subclass in the app |
|---|---|---|
SwiftDotNetAppDelegate : UIApplicationDelegate |
iOS / tvOS | [Register("AppDelegate")] class AppDelegate : SwiftDotNetAppDelegate |
SwiftDotNetAppDelegate : NSApplicationDelegate |
macOS | same (creates + sizes the NSWindow) |
SwiftDotNetActivity : ComponentActivity |
Android | [Activity(MainLauncher=true)] class MainActivity : SwiftDotNetActivity |
SwiftDotNetApplication : Application |
Windows | class App : SwiftDotNetApplication |
Each override is just protected override SwiftDotNetApp CreateSwiftApp() => SwiftProgram.CreateSwiftApp();
— the MAUI MauiProgram.cs shape. SwiftProgram is the single place the app registers services, logging and
its root view; the host base takes the built app's provider and passes it to SwiftApp.Run, so views can
reach services via [Inject] / Service<T>(). See
Hosting & Dependency Injection.
The bases are non-generic abstract classes — a generic
NSObject/Java.Lang.Objectsubclass can't be registered with the ObjC/Android runtimes.
@Observable(SwiftUI) andmutableStateOf(Compose) require iOS 17+ / the observable model; Compose strong-skipping means an in-place VNode mutation is skipped unless props/children are observable.- JSON is hand-rolled (
NodeJson) precisely to stay trim/AOT-safe. Datecrosses the bridge as Unix epoch seconds;ColorPickeras a hex string.
For where the architecture is still open (DI, per-view reconciliation, binary protocol), see the Roadmap.