DEV Community

Aaron LaBeau
Aaron LaBeau

Posted on

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

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:

  1. A three-pane layout with a Sidebar, detail view, and Inspector (SwiftUI's NavigationSplitView + .inspector)
  2. 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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 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.

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.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.

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

Now if you'll excuse me, I have NavigationSplitView animations to jealously admire.

Top comments (0)