What it is. A composition root — SwiftProgram.CreateSwiftApp() — where an app registers its
services, logging, and root view, plus the machinery that delivers those services to views. It mirrors
.NET MAUI's MauiProgram.cs deliberately: SwiftDotNetApp.CreateBuilder(), builder.Services,
builder.Logging, UseX() extensions, and per-platform heads that do nothing but call it.
The SwiftUI analog is @EnvironmentObject / @Environment, but the model here is standard .NET DI
(Microsoft.Extensions.DependencyInjection), so it behaves the way .NET developers already expect.
One shared file, used by every backend:
// sample/SharedUI/SwiftProgram.cs
public static class SwiftProgram
{
public static SwiftDotNetApp CreateSwiftApp(Action<SwiftDotNetAppBuilder>? platform = null)
{
var builder = SwiftDotNetApp.CreateBuilder();
builder.UseSwiftApp(_ => new SampleRootView()); // the root view
builder.Services.AddSingleton<IGreetingService, GreetingService>();
builder.Services.AddSingleton<IViewLifecycle, ConsoleViewLifecycle>();
builder.Logging.AddDebug();
platform?.Invoke(builder); // platform-only opt-ins
return builder.Build();
}
}Each platform head is then one line:
[Register("AppDelegate")]
public sealed class AppDelegate : SwiftDotNetAppDelegate
{
protected override SwiftDotNetApp CreateSwiftApp() => SwiftProgram.CreateSwiftApp(b => b.UseAppleMaps());
}builder.Configuration is not exposed. ConfigurationManager's binding is reflection-based and is
the usual trim casualty under iOS AOT; it can be added when there is a concrete need.
| Form | Use when |
|---|---|
builder.UseSwiftApp(sp => new ContentView()) |
Preferred. No runtime constructor discovery, so it is trim/AOT-clean. |
builder.UseSwiftApp<ContentView>() |
Convenient; resolves through ActivatorUtilities, which can surface trim warnings. |
The root is retained for the app's lifetime, so CreateRoot() builds it once and returns the same
instance on repeat calls.
Three delivery styles, in order of preference:
public sealed partial class WeatherView : View // the type must be partial
{
[Inject] public partial IWeatherService Weather { get; } // required
[Inject] public partial IImageCache? Cache { get; } // nullable ⇒ optional
}A source generator emits the implementation — plain static assignments, no reflection, which is what keeps this trim/AOT-clean:
// <auto-generated/>
partial class WeatherView : global::SwiftDotNet.IInjectable
{
private global::IWeatherService? __inject_Weather;
public partial global::IWeatherService Weather => __inject_Weather ?? throw new ...;
void global::SwiftDotNet.IInjectable.Inject(global::System.IServiceProvider provider)
{
__inject_Weather = global::SwiftDotNet.SwiftHost.Require<global::IWeatherService>(provider);
__inject_Cache = global::SwiftDotNet.SwiftHost.Optional<global::IImageCache>(provider);
}
}The legacy form [Inject] public IFoo Foo { get; set; } = default!; still works and is assigned
directly, but it has no uninjected-read diagnostic — it just hands back null — so prefer the partial form.
Works for anything the container builds, i.e. the root:
builder.UseSwiftApp(sp => new WeatherView(sp.GetRequiredService<IWeatherService>()));Views built inline inside a Body are rebuilt on every render pass and never pass through the
container, so they can take neither constructor services nor [Inject]. They use the locator:
public sealed class AuditButton : View
{
public override View Body =>
new Button("Refresh", () => { Service<IAudit>().Log("refresh"); });
}Two surfaces, raised by the same dispatch.
A view's own hooks — override what you need:
public sealed partial class DetailView : View
{
protected override void OnCreated() { } // constructed + [Inject] filled, before first render
protected override void OnAppearing() { } // became visible; can fire more than once
protected override void OnDisappearing() { } // no longer visible
protected override void OnDestroyed() { } // torn down for good
}IViewLifecycle — a cross-cutting observer, registered in the container. Every registered
implementation is called for every retained view:
public interface IViewLifecycle
{
void OnCreated(View view);
void OnAppearing(View view);
void OnDisappearing(View view);
void OnDestroyed(View view);
}
builder.Services.AddSingleton<IViewLifecycle, AnalyticsViewLifecycle>();Ordering. Setup (OnCreated, OnAppearing) runs observers → view; teardown (OnDisappearing,
OnDestroyed) runs view → observers. Dependency injection is itself just a registered observer, which
is what guarantees [Inject] members are filled before your own OnCreated runs.
ISwiftInitializer is the analog of MAUI's IMauiInitializeService / IMauiInitializeScopedService,
collapsed into one interface:
public interface ISwiftInitializer
{
void Initialize(IServiceProvider services, bool scoped);
}Called with scoped: false once during Build() against the app provider, and with scoped: true each
time a ViewScope is created — before anything else resolves from that scope. Invocation order is
registration order.
ViewScope pairs a retained view with its own IServiceScope, so scoped registrations resolve per view
and are disposed with it:
using var page = ViewScope.Create(app.Services, sp => new DetailView());
page.Appearing();
// … page.View is rendered; Service<T>() inside page.Use(...) resolves from this scopeSwiftHost.EnterScope(...) sets the ambient provider so Service<T>() and [Inject] resolve from the
scope; outside it, resolution falls back to the app provider.
Status:
ViewScopeis built and tested but has no production caller yet — the root is deliberately not scoped (it lives as long as the app, so a scope buys nothing), and per-page scopes arrive with the navigation service. Seeplans/navigation-service-plan.md(paused).
| Backend | Composition root | Notes |
|---|---|---|
| iOS / tvOS / macOS | SwiftDotNetAppDelegate.CreateSwiftApp() |
Provider flows into SwiftDotNetHost.CreateRootController(root, services). |
| Android | SwiftDotNetActivity.CreateSwiftApp() |
Via CreateRootView(context, root, services). |
| Windows | SwiftDotNetApplication.CreateSwiftApp() |
Via CreateRootElement(root, services). |
| Linux / GTK | Program.Main |
SwiftDotNetHost.Run(app.CreateRoot(), services: app.Services). |
| Skia | Program.Main |
SwiftApp.Run(app.CreateRoot(), bridge, app.Services). |
| Web / Blazor | Blazor's own container | Reuses Blazor's provider — SwiftProgram.AddSharedServices(builder.Services), then SwiftHost.Services = host.Services. One registration list serves both. |
Optional SwiftDotNet libraries extend the builder, mirroring UseMauiCommunityToolkit():
| Extension | Package | Effect |
|---|---|---|
UseAppleMaps() |
SwiftDotNet.Maps.Apple |
Native MapKit renderer for Map. |
UseAppleCamera() |
SwiftDotNet.Controls.Camera.Apple |
Native AVFoundation renderer for CameraView. |
UseMapLibreMaps() |
SwiftDotNet.Maps.Web |
MapLibre GL renderer for Map on Web. |
These perform process-wide native/renderer registration rather than container registration — the builder is simply the one place an app declares what it uses.
[Inject]only works on views the container creates — the root today, pushed pages once navigation lands. InlineBodychildren are rebuilt every render and are never injected; the generator reportsSDN1003when it can tell. That check is a heuristic (it looks for the view as a generic type argument or constructed in a registration lambda), so a view registered from another assembly can false-positive.- Views with
[Inject]must bepartial, and the partial-property form needs C# 13+. - In-repo
ProjectReferenceconsumers must reference the generator explicitly — analyzers don't flow transitively. Package consumers get it automatically:<ProjectReference Include="..\..\src\SwiftDotNet.SourceGenerators\SwiftDotNet.SourceGenerators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
- Blazor also has an
[Inject]attribute. Any file importing bothSwiftDotNetandMicrosoft.AspNetCore.Componentsmust qualify which one it means. SwiftHost.ActiveScopeis a plain static, valid across synchronous UI-thread work. Anasync voidhandler that awaits resumes after the scope has exited — capture services before awaiting.- The core library now depends on
Microsoft.Extensions.DependencyInjection(and.Logging). The render path itself stays free of them —SwiftHostbinds only to BCLIServiceProvider.
| ID | Severity | Meaning |
|---|---|---|
SDN1001 |
Error | [Inject] on a non-partial type. |
SDN1002 |
Error | [Inject] on a property that can't be assigned (non-partial get-only, or init-only). |
SDN1003 |
Warning | [Inject] members will never be filled — the view is never container-created. |
| Piece | Status |
|---|---|
Builder, SwiftHost, [Inject] generator, lifecycle, initializers |
✅ Verified — 107 tests, and the Skia headless harness boots the sample through the container on macOS |
| Platform heads (Apple, Android, GTK, Skia, Web) | ✅ Build verified on macOS; Apple/Android/GTK/Skia/Web compile, Skia + sample run |
| Windows head | 🧩 Updated but unverified — needs Windows to build |
ViewScope / scoped per-page services |
🧩 Built and tested, no production caller until navigation lands |
Navigation service (INavigator) |
⏸ Paused — plans/navigation-service-plan.md |
plans/dependency-injection-proposal.md— the design and the ratified decisions behind it.- Architecture — where hosting sits relative to the render loop.
- Custom Controls — the renderer registry the
UseX()methods drive. - Source:
SwiftHost.cs,Core/Hosting/,SwiftDotNet.SourceGenerators.