Let me start with a confession: I love SwiftUI. I don't love the fact that roughly 70% of developers outside of Apple's walled garden can run my SwiftUI app. That math doesn't work when you're building a developer tool.
So when I set out to ship Ditto Edge Studio — a debug and query tool for the Ditto edge database — I needed it to run on Windows, Linux, and macOS. On the Mac I already had a polished SwiftUI build. For everyone else, I turned to Avalonia. This post is about what I learned bringing the two into the same family, specifically around two features that had no business being easy to port:
- A three-pane layout with a Sidebar, detail view, and Inspector (SwiftUI's
NavigationSplitView+.inspector) - A Presence Viewer — an animated network graph of peers — built in SpriteKit on macOS and reimplemented with SkiaSharp on .NET
Spoiler: it's pretty good. Not "identical-pixel-for-identical-pixel" good, but "I'd ship this to customers tomorrow" good. Let me show you.
The Three-Pane Layout: Sidebar + Detail + Inspector
On macOS, SwiftUI hands you this layout on a silver platter. NavigationSplitView gives you the sidebar. .inspector gives you the right-hand panel. You write about twelve lines and Apple's engineers do the rest of the work while you sip a latte.
SwiftUI: The Frictionless Version
struct MainStudioView: View {
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var showInspector = false
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
// Sidebar
unifiedSidebarView()
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 300)
} detail: {
// Center content — switches on the selected sidebar item
Group {
switch viewModel.selectedSidebarMenuItem.name {
case "Collections": queryDetailView()
case "Observers": observeDetailView()
default: syncTabsDetailView()
}
}
.id(viewModel.selectedSidebarMenuItem)
.transition(.blurReplace)
}
.navigationSplitViewStyle(.prominentDetail)
.inspector(isPresented: $showInspector) {
inspectorView()
.inspectorColumnWidth(min: 250, ideal: 350, max: 500)
}
}
}
That's it. Resizable columns, animated transitions, proper collapsing on narrow windows, native macOS chrome, the works. If you've never built this before, you won't appreciate how much is happening for free. If you have built it before — on, say, WPF in 2014 — you're probably reading this through quiet tears of envy.
Avalonia: Assembling the Ikea Version
Avalonia ships a SplitView control, but it's a two-pane component designed for the classic hamburger-nav pattern. Three panes with independent resizing means rolling your own with a Grid and a couple of GridSplitters. Is it as elegant as SwiftUI? No. Is it actually fine and kind of fun to build? Eh, if you have AI to help you find the XAML bugs, then yes.
Here's the load-bearing XAML from EdgeStudioView.axaml:
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<!-- Icon nav rail (always visible) -->
<ColumnDefinition Width="48" MinWidth="48" MaxWidth="60"/>
<!-- Splitter 1 -->
<ColumnDefinition Width="Auto"/>
<!-- Sidebar listing panel -->
<ColumnDefinition Width="Auto"/>
<!-- Splitter 2 -->
<ColumnDefinition Width="Auto"/>
<!-- Detail view (takes the rest) -->
<ColumnDefinition Width="*" MinWidth="400"/>
<!-- Inspector splitter (only shown when inspector is open) -->
<ColumnDefinition Width="Auto"/>
<!-- Inspector panel (collapses via IsVisible) -->
<ColumnDefinition Width="280" MinWidth="200"/>
</Grid.ColumnDefinitions>
<navigation:NavigationBar Grid.Column="0"
DataContext="{Binding NavigationViewModel}"/>
<GridSplitter Grid.Column="1" Width="5"
ResizeDirection="Columns"
IsVisible="{Binding IsListingPanelVisible}"/>
<Panel Grid.Column="2" MinWidth="200" MaxWidth="500"
IsVisible="{Binding IsListingPanelVisible}">
<ContentControl Content="{Binding CurrentListingViewModel}">
<ContentControl.DataTemplates>
<DataTemplate DataType="{x:Type vm:QueryViewModel}">
<sidebar:QueryListingView/>
</DataTemplate>
<DataTemplate DataType="{x:Type vm:SubscriptionViewModel}">
<sidebar:SubscriptionListingView/>
</DataTemplate>
<!-- ...etc. -->
</ContentControl.DataTemplates>
</ContentControl>
</Panel>
<GridSplitter Grid.Column="3" Width="5" ResizeDirection="Columns"/>
<ContentControl Grid.Column="4" Content="{Binding CurrentDetailViewModel}"/>
<GridSplitter Grid.Column="5" Width="5"
ResizeDirection="Columns"
IsVisible="{Binding IsInspectorVisible}"/>
<Panel Grid.Column="6" IsVisible="{Binding IsInspectorVisible}">
<!-- Inspector content here -->
</Panel>
</Grid>
The pattern — DataTemplates keyed to ViewModel types inside a ContentControl — is Avalonia's idiomatic answer to SwiftUI's switch over an enum inside a Group. The MVVM ViewModel swaps, and Avalonia's ViewLocator or inline templates pick the matching view. Same conceptual dance, just with more angle brackets.
What I Lost (Being Honest)
-
Transitions. SwiftUI's
.transition(.blurReplace)is a one-liner. In Avalonia I'd need to animate opacity/translation myself onDataContextchange. I skipped it. Nobody filed a bug. -
The collapse-on-narrow-window magic. SwiftUI knows when to hide the sidebar. On Avalonia I decide via a bound
IsListingPanelVisibleproperty and a toggle button. -
Native macOS chrome. On Avalonia I use SukiUI's
SukiWindow, which looks great and consistent on all three OSes — but it's not Apple-native. That's a feature, not a bug: the whole point was consistency across Windows/Linux/macOS.
What I Gained
One codebase. Three operating systems. Zero Electron. Native compiled performance with dotnet publish -r win-x64 --self-contained. I'll take that trade.
The Presence Viewer: SpriteKit vs. SkiaSharp
This is where things got interesting. The Presence Viewer shows a live network diagram of Ditto peers — nodes floating around, edges animating between them as connections come and go, color-coded by transport (Bluetooth, P2P Wi-Fi, WebSocket, etc.). It's the feature that makes people go "oh, cool" in demos. Losing it in the Avalonia build was not an option.
SwiftUI: SpriteKit Does the Heavy Lifting
On macOS I used SpriteKit — Apple's 2D game engine. Overkill for a debug UI? Absolutely. And that's the point. I get a physics-backed scene graph, built-in node dragging, and buttery 60fps animations that I didn't have to think about.
import SpriteKit
import SwiftUI
struct PresenceViewerSK: View {
@State private var scene: PresenceNetworkScene?
var body: some View {
ZStack(alignment: .bottomTrailing) {
SpriteKitSceneView(scene: $scene, viewModel: viewModel)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.focusable()
// Overlay controls on top of the scene
VStack(alignment: .trailing, spacing: 8) {
directConnectedToggle
zoomControls
}
.padding(.trailing, 16)
.padding(.bottom, 72)
}
}
}
class PresenceNetworkScene: SKScene {
func updatePresenceGraph(localPeer: PeerProtocol,
remotePeers: [PeerProtocol]) {
// Diff peers, add/remove SKNodes, animate with SKAction
}
}
SpriteKit handles hit-testing, SKAction handles the tweens, and SKCameraNode gives me zoom and pan for the price of a couple of properties. If you ever wondered why Apple developers sometimes sound smug — this is why.
Avalonia: Hello, Skia
SpriteKit does not run on Windows. Shocker, I know. But Avalonia ships with SkiaSharp baked in — the same Skia that powers Chrome, Android, and sort of Flutter (it's been replaced by Impeller and I can see why after doing this). If you're drawing pixels on .NET and using Avalonia, Skia is your friend.
I built a custom Control that owns a WriteableBitmap, locks it every frame, and hands Skia an SKSurface to draw into:
public class PresenceGraphControl : Control
{
private WriteableBitmap? _bitmap;
private readonly PresenceGraphRenderer _renderer = new();
private readonly PresenceGraphAnimator _animator = new();
private DispatcherTimer? _animationTimer;
public static readonly StyledProperty<PresenceGraphSnapshot?> SnapshotProperty =
AvaloniaProperty.Register<PresenceGraphControl, PresenceGraphSnapshot?>(
nameof(Snapshot));
static PresenceGraphControl()
{
AffectsRender<PresenceGraphControl>(
SnapshotProperty, PositionsProperty, ZoomLevelProperty);
}
public override void Render(DrawingContext context)
{
var bounds = Bounds;
var pw = (int)bounds.Width;
var ph = (int)bounds.Height;
if (_bitmap == null ||
_bitmap.PixelSize.Width != pw ||
_bitmap.PixelSize.Height != ph)
{
_bitmap?.Dispose();
_bitmap = new WriteableBitmap(
new PixelSize(pw, ph),
new Vector(96, 96),
PixelFormats.Bgra8888,
AlphaFormat.Premul);
}
using var locked = _bitmap.Lock();
var info = new SKImageInfo(pw, ph,
SKColorType.Bgra8888, SKAlphaType.Premul);
using var surface = SKSurface.Create(
info, locked.Address, locked.RowBytes);
_renderer.Draw(surface.Canvas, Snapshot,
GetEffectivePositions(), _zoom, _panX, _panY);
context.DrawImage(_bitmap, new Rect(bounds.Size));
}
}
The AffectsRender call is the Avalonia equivalent of saying "hey framework, when any of these properties change, please call Render again." A DispatcherTimer ticks 60 times a second to drive the animator, which interpolates node positions with a simple spring-ish easing. Pointer events get routed from OnPointerPressed / OnPointerMoved to figure out whether the user is panning the camera or dragging an individual node.
The renderer itself is refreshingly boring C#:
public class PresenceGraphRenderer
{
private static readonly Dictionary<string, SKColor> ConnectionColors = new()
{
["Bluetooth"] = new SKColor(0, 102, 217),
["AccessPoint"] = new SKColor(13, 133, 64),
["P2PWifi"] = new SKColor(199, 26, 56),
["WebSocket"] = new SKColor(217, 122, 0),
["AWDL"] = new SKColor(136, 68, 221),
["Cloud"] = new SKColor(115, 38, 184),
};
public void Draw(SKCanvas canvas, PresenceGraphSnapshot? snap, /* ... */)
{
canvas.Clear(SKColors.Transparent);
DrawEdges(canvas, snap);
DrawNodes(canvas, snap);
DrawLegend(canvas);
}
}
The Honest Trade-Off
SpriteKit's animations are noticeably nicer. When a peer drops off the mesh, SwiftUI gives me a particle-poof-esque fade that took zero effort. On Skia I get a clean alpha interpolation. It's smooth, it's readable, it does the job — but it isn't magic. If your users are going to stare at this for hours, SpriteKit wins. If they glance at it to debug a sync issue and move on, nobody notices.
What Skia does give me:
- Identical rendering on Windows, Linux, and macOS. No surprises.
- Deterministic control. Every pixel is my fault, which is occasionally humbling and often useful when debugging.
-
Testability. I can unit-test the renderer and animator without standing up a UI —
PresenceGraphRendererTests.csis a real thing in the repo. - Performance. Skia is not slow. It's what your web browser uses. It does not need your pity.
So, Should You Use Avalonia?
If any of these describe you, yes:
- You need one codebase for Windows, Linux, and macOS and you don't want to pay the Electron tax.
- You already know C# / XAML, or you're happy to learn MVVM (it's not scary; it's WPF with better manners).
- You can live without a few SwiftUI luxuries —
.transition, implicit animations, the occasional bit of "how did that just work?" Apple magic.
If you're shipping Mac-only and SwiftUI meets your needs, keep shipping SwiftUI. It's a wonderful framework designed by people who clearly enjoy their jobs.
But for everyone else? Avalonia isn't a compromise — it's a serious, production-ready option that happens to run everywhere. I ported a real app. I shipped a real animated graph. I did not cry (much). My Linux users can finally stop asking if there will ever be a Linux version available.
And honestly, there's something poetic about Microsoft-stewarded .NET running a native app on Linux to manage an edge database that syncs over Bluetooth. We live in weird, wonderful times.
Links
- Avalonia UI: avaloniaui.net
- SukiUI (the theming I use): github.com/kikipoulet/SukiUI
- SkiaSharp: github.com/mono/SkiaSharp
- Ditto: ditto.live
Now if you'll excuse me, I have NavigationSplitView animations to jealously admire.
Top comments (0)