The three Apple platforms share one Swift interpreter (native/SwiftDotNetBridge/Sources/.../Bridge.swift)
that reconstructs real SwiftUI from the C# node tree. This is a native-shim backend (SwiftUI is a
compiler-plugin framework — you can't author a SwiftUI View from C#).
- Verified: iOS (iPhone Air / iOS 26.5 simulator), macOS (desktop
NSWindow+NSHostingController), tvOS (Apple TV 4K simulator). - Route: C# owns the tree; a Swift shim reconstructs SwiftUI. Native fidelity, no layout engine to reimplement.
C# talks to the xcframework over a C ABI (@_cdecl entry points, P/Invoke via DllImport("__Internal")):
| Symbol | Role |
|---|---|
swiftdotnet_render(json) |
Apply a patch (replace / updateProps / setChildren) to the observed @Observable VNode tree. |
swiftdotnet_set_event_callback(fn) |
Swift calls it on events with a node id + optional value. |
swiftdotnet_make_host_controller() |
Returns a UIHostingController (iOS/tvOS) / NSHostingController (macOS) C# hosts as root. |
Two-way controls (TextField, Toggle) are SwiftUI controlled components whose local @State syncs both
directions via onChange. @Observable requires iOS 17+ / macOS 14+ / tvOS 17+.
-
macOS swaps UIKit → AppKit hosting via
#if canImport(UIKit)/#elseif canImport(AppKit):UIHostingController→NSHostingController,UIColor→NSColor. PagedTabViewis guarded off (PageTabViewStyleis iOS-only). -
tvOS is focus-driven with no pointer, so Apple marks several SwiftUI controls unavailable there. The bridge adds
#if os(tvOS)fallbacks (compile errors otherwise):Control tvOS fallback SliderText(value)Stepperfocusable −/+ Buttons that emit value±1 (stays functional) DatePickerText(formatted date)ColorPickerText+RoundedRectangleswatchDisclosureGroupheader Button + conditional children GaugeVStack { label + ProgressView }TextEditorTextFieldpaged TabViewstandard TabView
Neither container maps onto anything SwiftUI ships. LazyVGrid sizes columns but has no concept of a cell
span or an explicit cell; SwiftUI's own Grid has gridCellColumns but no row span and no per-column
sizing; and there is no absolute-positioning container at all. So the bridge implements both against the
Layout protocol — SDNGridLayout and SDNAbsoluteLayout — which is available from iOS 16 / macOS 13 /
tvOS 16, all below this bridge's deployment targets (17.0 / 14.0 / 17.0).
Placement is resolved before the layout runs, in gridView, where the whole child list is in hand; each
subview then carries its cell as a LayoutValueKey. The track-sizing algorithm is a line-for-line port of
SkiaNode.ResolveTracks, so the same C# lays out identically on Skia and SwiftUI. List.Columns(n) is
unaffected — it still lowers to a virtualizing LazyVGrid. See Grid.
native/SwiftDotNetBridge/build-xcframework.shProduces build/SwiftDotNetBridge.xcframework with 5 slices: ios-arm64, ios-arm64-simulator,
tvos-arm64, tvos-arm64-simulator, macos-arm64. macOS is assembled as a versioned framework
(Versions/A/… with symlinks), unlike the flat iOS/tvOS layout. Min iOS/tvOS 17, min macOS 14 (for
@Observable).
src/SwiftDotNetis one multi-target library;Platforms/{iOS,macOS,tvOS}/holdIosBridge/MacBridge/TvBridge(all near-identical:__InternalP/Invoke, host-controller pointer →UIViewController/NSViewController) andSwiftDotNetHost.- The Apple
NativeReferenceis declared insrc/SwiftDotNet/SwiftDotNetBridge.targets(gated to ios/macos/tvos). - Reusable host bases:
SwiftDotNetAppDelegate(iOS/tvOS: UIApplicationDelegate, macOS: NSApplicationDelegate— owns the NSWindow sizing fix).
See Getting Started for build/run commands.
NativeReferencedoesn't flow transitively — the app's.csprojmust also<Import Project="…/SwiftDotNetBridge.targets" />, or the link fails withUndefined symbols _swiftdotnet_*.DllImport("__Internal"), not a leaf-namedlopen(which ignores@rpath).- macOS window sizing: add the host view as a resizable subview filling the
NSWindow; settingContentViewControllercollapses the window to the SwiftUI intrinsic size (the 213×92 window bug). - iOS launch screen / letterbox: the
Info.plistneedsLink="Info.plist"in the csprojNoneitem so the SDK's_DetectAppManifestpicks it up (else noUILaunchScreen, full-screen letterbox). - Safe area is iOS-only, not macOS/tvOS. The
safeAreaPadding/ignoresSafeAreacases inapplyModifiersare inside#if os(iOS); the other Apple slices fall through todefault: break, and the C# API is annotated so those platforms can't call it. Insets are read from the key window (not aGeometryReader) by a zero-sizeSafeAreaReporteroverlay, so reporting contributes nothing to layout — wrapping the root in aGeometryReaderwould have re-aligned every existing app. Keyboard height comes fromUIResponder.keyboardWillChangeFrame/WillHide, which SwiftUI has no equivalent for. See Safe area. - Maps: MapKit ships as a separate companion xcframework (
SwiftDotNetMaps), registered viaAppleMaps.Register()— it stays out of the core bridge. See Maps. - Alert / ActionSheet buttons need a dismissal guard. SwiftUI dismisses the dialog itself when a
button is tapped, which fires the same
onChange(of: presented)that a scrim dismissal does — so the index the button emitted would be followed by a spurious"false".AlertNode/ActionSheetNoderecord the chosen index in a@Stateand let whichever of the two runs second stay quiet; the order between the button action and the dismissal is not documented, so neither side may assume it goes first. See Alerts & action sheets. - Keyframe timelines use a real
KeyframeAnimator, so SwiftUI owns the clock (the deployment floor is already iOS 17 / macOS 14 / tvOS 17, which is exactly whatKeyframeAnimatorneeds — no availability guard). Three things the implementation is deliberately shaped around, all inBridge.swift:- All eight property tracks are emitted unconditionally, with untracked ones holding a constant. Eight
optional tracks is 2⁸ type combinations in the
keyframesresult builder, which blows the Swift type-checker up so badly it can't even produce a diagnostic (failed to produce diagnostic for expression). If you add a property, keep it unconditional. KFValueshand-rollsanimatableDataas a tree ofAnimatablePairs.KeyframeAnimatorrequiresAnimatable, and the defaultanimatableDataonly exists forVectorArithmetictypes.autoreverseis a mirrored return leg, not a flag —KeyframeAnimatoronly loops forwards. A finite repeat count above 1 loops forever, sincerepeating:is all-or-nothing. See keyframe animations.
- All eight property tracks are emitted unconditionally, with untracked ones holding a constant. Eight
optional tracks is 2⁸ type combinations in the
UseInterpreter=true (opt in with
-p:SwiftDotNetHotReload=true), and with it the app installs and launches but aborts during startup —
Socket error while connecting to IDE on 127.0.0.1:10000: Connection refused — because bare dotnet watch
does not stand up the IDE-side debug tunnel the interpreter build expects. Drive hot reload from Visual
Studio, VS Code, or Rider instead. Nothing in the Swift shim is involved either way: a reload is an
ordinary replace patch. See Hot Reload.