AI News Hub Logo

AI News Hub

Porting a SwiftUI App to Avalonia: How does Cross-Platform .NET hold up

DEV Community
Aaron LaBeau

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 — our 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. 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. 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 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: 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. Transitions. SwiftUI's .transition(.blurReplace) is a one-liner. In Avalonia I'd need to animate opacity/translation myself on DataContext change. 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 IsListingPanelVisible property 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. One codebase. Three operating systems. Zero Electron. Native compiled performance with dotnet publish -r win-x64 --self-contained. I'll take that trade. 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. 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. 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 SnapshotProperty = AvaloniaProperty.Register( nameof(Snapshot)); static PresenceGraphControl() { AffectsRender( 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 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); } } 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.cs is a real thing in the repo. Performance. Skia is not slow. It's what your web browser uses. It does not need your pity. 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. 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.